From f049736c8a7ff4cd1c06bcea9606831dab73e4be Mon Sep 17 00:00:00 2001 From: kurihada Date: Wed, 25 Mar 2026 19:59:42 +0800 Subject: [PATCH] feat(docs): add analytics and pattern showcase stories --- apps/docs/src/analytics-workspace.stories.tsx | 1104 +++++++++++++++ apps/docs/src/components/chart.stories.tsx | 346 +++++ apps/docs/src/components/gauge.stories.tsx | 301 +++++ apps/docs/src/components/grid.stories.tsx | 238 ++++ .../src/components/input-group.stories.tsx | 176 +++ .../src/components/metric-card.stories.tsx | 236 ++++ .../components/segmented-control.stories.tsx | 175 +++ apps/docs/src/components/sparkbar.stories.tsx | 232 ++++ .../docs/src/components/stat-card.stories.tsx | 206 +++ .../src/components/value-field.stories.tsx | 172 +++ apps/docs/src/patterns/app-shell.stories.tsx | 274 ++++ .../patterns/challenge-progress.stories.tsx | 200 +++ .../docs/src/patterns/page-footer.stories.tsx | 102 ++ .../docs/src/patterns/page-header.stories.tsx | 135 ++ .../docs/src/patterns/sidebar-nav.stories.tsx | 236 ++++ .../src/patterns/two-factor-setup.stories.tsx | 372 +++++ .../patterns/workspace-toolbar.stories.tsx | 170 +++ apps/docs/src/release-workspace.stories.tsx | 1204 ----------------- apps/docs/src/revenue-dashboard.stories.tsx | 977 +++++++++++++ .../2026-03-25-analytics-workspace-scene.md | 54 + ...2026-03-25-analytics-workspace-showcase.md | 59 + 21 files changed, 5765 insertions(+), 1204 deletions(-) create mode 100644 apps/docs/src/analytics-workspace.stories.tsx create mode 100644 apps/docs/src/components/chart.stories.tsx create mode 100644 apps/docs/src/components/gauge.stories.tsx create mode 100644 apps/docs/src/components/grid.stories.tsx create mode 100644 apps/docs/src/components/input-group.stories.tsx create mode 100644 apps/docs/src/components/metric-card.stories.tsx create mode 100644 apps/docs/src/components/segmented-control.stories.tsx create mode 100644 apps/docs/src/components/sparkbar.stories.tsx create mode 100644 apps/docs/src/components/stat-card.stories.tsx create mode 100644 apps/docs/src/components/value-field.stories.tsx create mode 100644 apps/docs/src/patterns/app-shell.stories.tsx create mode 100644 apps/docs/src/patterns/challenge-progress.stories.tsx create mode 100644 apps/docs/src/patterns/page-footer.stories.tsx create mode 100644 apps/docs/src/patterns/page-header.stories.tsx create mode 100644 apps/docs/src/patterns/sidebar-nav.stories.tsx create mode 100644 apps/docs/src/patterns/two-factor-setup.stories.tsx create mode 100644 apps/docs/src/patterns/workspace-toolbar.stories.tsx delete mode 100644 apps/docs/src/release-workspace.stories.tsx create mode 100644 apps/docs/src/revenue-dashboard.stories.tsx create mode 100644 docs/exec-plans/2026-03-25-analytics-workspace-scene.md create mode 100644 docs/exec-plans/2026-03-25-analytics-workspace-showcase.md diff --git a/apps/docs/src/analytics-workspace.stories.tsx b/apps/docs/src/analytics-workspace.stories.tsx new file mode 100644 index 0000000..69aa8d6 --- /dev/null +++ b/apps/docs/src/analytics-workspace.stories.tsx @@ -0,0 +1,1104 @@ +import { useId, useMemo, useState } from "react"; + +import { + AppShell, + AppShellBody, + AppShellHeader, + AppShellMain, + AppShellSidebar, + Avatar, + AvatarFallback, + Badge, + Breadcrumb, + BreadcrumbCurrent, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbSeparator, + Button, + Chart, + ChartChange, + ChartDescription, + ChartHeader, + ChartHeaderAside, + ChartHeaderLeading, + ChartMetrics, + ChartTitle, + ChartValue, + Input, + InputGroup, + InputGroupPrefix, + InputGroupSuffix, + MetricCard, + MetricCardDescription, + MetricCardHeader, + MetricCardLabel, + MetricCardMedia, + PageHeader, + PageHeaderActions, + PageHeaderDescription, + PageHeaderEyebrow, + PageHeaderLeading, + PageHeaderMeta, + PageHeaderTitle, + SegmentedControl, + SegmentedControlItem, + SidebarNav, + SidebarNavContent, + SidebarNavFooter, + SidebarNavHeader, + SidebarNavItem, + SidebarNavItemIcon, + SidebarNavItemLabel, + SidebarNavItems, + SidebarNavSection, + SidebarNavSectionLabel, + StatCard, + StatCardDelta, + StatCardDescription, + StatCardHeader, + StatCardMetric, + StatCardValue, + WorkspaceToolbar, + WorkspaceToolbarActions, + WorkspaceToolbarContent, + WorkspaceToolbarLeading, + WorkspaceToolbarSearch, + WorkspaceToolbarStatus, + type ChartSeries, + type ChartTooltipContext +} from "@ai-ui/ui"; +import type { Meta, StoryObj } from "@storybook/react"; + +type NavigationIcon = + | "control" + | "quote" + | "shipments" + | "console" + | "portal" + | "documents" + | "analytics" + | "integrations" + | "training"; + +type NavigationItem = { + active?: boolean; + icon: NavigationIcon; + label: string; +}; + +type NavigationSection = { + items: NavigationItem[]; + label: string; +}; + +type KpiTone = "primary" | "success" | "warning" | "neutral"; +type RangeValue = "today" | "week" | "month"; +type AutomationWindow = "today" | "weekly"; + +type KpiCardDefinition = { + description: string; + delta: string; + label: string; + note: string; + tone: KpiTone; + trend: readonly number[]; + value: string; +}; + +type VolumeDatum = { + air: number; + day: string; + road: number; + sea: number; +}; + +type BookingDatum = { + booked: number; + day: string; + quoted: number; +}; + +type PlotPoint = { + x: number; + y: number; +}; + +const navigationSections: NavigationSection[] = [ + { + label: "Workspace", + items: [ + { icon: "control", label: "Control Tower" }, + { icon: "quote", label: "Generate Quote" }, + { icon: "shipments", label: "Track Shipments" }, + { icon: "console", label: "AI Console" }, + { icon: "portal", label: "Customer Portal" } + ] + }, + { + label: "Insight & Control", + items: [ + { icon: "documents", label: "Documents" }, + { active: true, icon: "analytics", label: "Analytics" }, + { icon: "integrations", label: "Integrations" }, + { icon: "training", label: "AI Training" } + ] + } +]; + +const kpiCards: KpiCardDefinition[] = [ + { + description: "Median assisted turnaround", + delta: "+46%", + label: "Quote response time", + note: "faster than manual", + tone: "warning", + trend: [12, 20, 18, 26, 24, 34, 29, 38, 35, 46, 43, 45], + value: "45 seconds" + }, + { + description: "Automated triage acceptance", + delta: "+8 pts", + label: "AI resolution rate", + note: "from last month", + tone: "success", + trend: [52, 58, 56, 64, 61, 68, 66, 74, 72, 79, 76, 92], + value: "92%" + }, + { + description: "Post-delivery sentiment index", + delta: "+0.3", + label: "Customer satisfaction", + note: "from last month", + tone: "primary", + trend: [34, 38, 36, 44, 42, 48, 46, 55, 52, 61, 57, 64], + value: "4.8/5.0" + }, + { + description: "Board-level quarterly impact", + delta: "+58%", + label: "Revenue impact", + note: "this quarter", + tone: "success", + trend: [18, 24, 22, 31, 28, 39, 36, 46, 41, 54, 47, 61], + value: "$1.25M" + } +]; + +const shipmentVolumeData: VolumeDatum[] = [ + { air: 48, day: "Sep 1", road: 124, sea: 208 }, + { air: 51, day: "Sep 2", road: 128, sea: 216 }, + { air: 58, day: "Sep 3", road: 142, sea: 228 }, + { air: 63, day: "Sep 4", road: 154, sea: 241 }, + { air: 72, day: "Sep 5", road: 166, sea: 258 }, + { air: 78, day: "Sep 6", road: 176, sea: 274 }, + { air: 86, day: "Sep 7", road: 188, sea: 296 } +]; + +const bookingMomentumData: BookingDatum[] = [ + { booked: 18, day: "Sep 1", quoted: 24 }, + { booked: 36, day: "Sep 2", quoted: 64 }, + { booked: 44, day: "Sep 3", quoted: 118 }, + { booked: 62, day: "Sep 4", quoted: 156 } +]; + +const automationLabels = [ + { label: "Rate Negotiations", ratio: 0.08 }, + { label: "Documentation", ratio: 0.28 }, + { label: "Customer Support", ratio: 0.48 }, + { label: "Tracking Updates", ratio: 0.68 }, + { label: "Quote Generation", ratio: 0.88 } +] as const; + +const automatedCurve = [0.8, 0.78, 0.52, 0.66, 0.56, 0.58, 0.18, 0.62] as const; +const manualCurve = [0.62, 0.54, 0.42, 0.46, 0.38, 0.28, 0.35, 0.14] as const; +const automationTicks = ["0.00", "0.25", "0.50", "0.75"] as const; + +const compactNumberFormatter = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 0, + notation: "compact" +}); + +function formatCompactNumber(value: number) { + return compactNumberFormatter.format(value); +} + +const volumeSeries: ChartSeries[] = [ + { + getValue: (datum) => datum.sea, + id: "sea", + label: "Sea", + style: "line-area", + tone: "success", + valueFormatter: formatCompactNumber + }, + { + getValue: (datum) => datum.air, + id: "air", + label: "Air", + style: "line", + tone: "primary", + valueFormatter: formatCompactNumber + }, + { + getValue: (datum) => datum.road, + id: "road", + label: "Road", + style: "line", + tone: "warning", + valueFormatter: formatCompactNumber + } +]; + +const bookingSeries: ChartSeries[] = [ + { + getValue: (datum) => datum.quoted, + id: "quoted", + label: "Quote", + style: "line", + tone: "primary", + valueFormatter: formatCompactNumber + }, + { + getValue: (datum) => datum.booked, + id: "booked", + label: "Booking", + style: "line", + strokeDasharray: "6 7", + tone: "neutral", + valueFormatter: formatCompactNumber + } +]; + +function getToneStroke(tone: KpiTone) { + switch (tone) { + case "success": + return "var(--color-success)"; + case "warning": + return "var(--color-warning)"; + case "neutral": + return "var(--color-border-strong)"; + case "primary": + default: + return "var(--color-primary)"; + } +} + +function getToneFill(tone: KpiTone) { + switch (tone) { + case "success": + return "color-mix(in oklch, var(--color-success) 18%, transparent)"; + case "warning": + return "color-mix(in oklch, var(--color-warning) 18%, transparent)"; + case "neutral": + return "color-mix(in oklch, var(--color-border-strong) 14%, transparent)"; + case "primary": + default: + return "color-mix(in oklch, var(--color-primary) 18%, transparent)"; + } +} + +function buildSmoothPath(points: PlotPoint[]) { + if (points.length === 0) { + return ""; + } + + if (points.length === 1) { + const point = points[0]; + return `M ${point.x} ${point.y}`; + } + + let path = `M ${points[0].x} ${points[0].y}`; + + for (let index = 0; index < points.length - 1; index += 1) { + const previous = points[index - 1] ?? points[index]; + const current = points[index]; + const next = points[index + 1]; + const afterNext = points[index + 2] ?? next; + const controlPointOneX = current.x + (next.x - previous.x) / 6; + const controlPointOneY = current.y + (next.y - previous.y) / 6; + const controlPointTwoX = next.x - (afterNext.x - current.x) / 6; + const controlPointTwoY = next.y - (afterNext.y - current.y) / 6; + + path += ` C ${controlPointOneX} ${controlPointOneY} ${controlPointTwoX} ${controlPointTwoY} ${next.x} ${next.y}`; + } + + return path; +} + +function buildAreaPath(points: PlotPoint[], baselineY: number) { + if (points.length === 0) { + return ""; + } + + return `${buildSmoothPath(points)} L ${points[points.length - 1].x} ${baselineY} L ${points[0].x} ${baselineY} Z`; +} + +function getNormalizedPoints(values: readonly number[], width: number, height: number, padding: number) { + const min = Math.min(...values); + const max = Math.max(...values); + const horizontalStep = values.length > 1 ? (width - padding * 2) / (values.length - 1) : 0; + + return values.map((value, index) => { + const ratio = max > min ? (value - min) / (max - min) : 0.5; + + return { + x: padding + horizontalStep * index, + y: height - padding - ratio * (height - padding * 2) + }; + }); +} + +function LogisticsMark() { + return ( + + + + ); +} + +function SearchIcon() { + return ( + + ); +} + +function SparkIcon() { + return ( + + ); +} + +function BellIcon() { + return ( + + ); +} + +function SidebarGlyph({ icon }: { icon: NavigationIcon }) { + switch (icon) { + case "control": + return ( + + ); + case "quote": + return ( + + ); + case "shipments": + return ( + + ); + case "console": + return ( + + ); + case "portal": + return ( + + ); + case "documents": + return ( + + ); + case "analytics": + return ( + + ); + case "integrations": + return ( + + ); + case "training": + return ( + + ); + } +} + +function AnalyticsTooltip({ activeLabel, series }: ChartTooltipContext) { + return ( +
+
+ {activeLabel} +
+
+ {series.map((entry) => ( +
+ + {entry.label} + {entry.formattedValue} +
+ ))} +
+
+ ); +} + +function TrendSparkline({ + className, + tone, + values +}: { + className?: string; + tone: KpiTone; + values: readonly number[]; +}) { + const width = 208; + const height = 62; + const padding = 6; + const gradientId = useId(); + const points = useMemo( + () => getNormalizedPoints(values, width, height, padding), + [values, width, height, padding] + ); + const stroke = getToneStroke(tone); + const fill = getToneFill(tone); + const areaPath = buildAreaPath(points, height - padding); + const linePath = buildSmoothPath(points); + const lastPoint = points[points.length - 1]; + + return ( +
+ +
+ ); +} + +function KpiCard({ definition, index }: { definition: KpiCardDefinition; index: number }) { + const iconBackgrounds = [ + "bg-[color-mix(in_oklch,var(--color-warning)_18%,white_82%)]", + "bg-[color-mix(in_oklch,var(--color-success)_16%,white_84%)]", + "bg-[color-mix(in_oklch,var(--color-primary)_14%,white_86%)]", + "bg-[color-mix(in_oklch,var(--color-tertiary-container)_48%,white_52%)]" + ]; + + return ( + + +
+
+

{definition.label}

+

+ {definition.description} +

+
+ + + +
+
+ + + {definition.value} + + {definition.delta} + + + + {definition.note} + + +
+ ); +} + +function AnalyticsSidebar() { + return ( + + +
+
+ +
+

+ Cadence Freight +

+

+ Logistics operating system +

+
+
+
+
+ + + + {navigationSections.map((section) => ( + + {section.label} + + {section.items.map((item) => ( + + + + + {item.label} + + ))} + + + ))} + + + +
+
+
+

+ Workspace owner +

+

Alex Chen

+

Cadence Logistics

+
+ + AC + +
+
+ + + +
+
+
+ + ); +} + +function TopWorkspaceBar() { + return ( + + + + + + Dashboard + + + + Analytics + + + + + + + + + + + + + + / + + + + + + + Ops pulse stable + + + Synced 2 min ago + + + + + + + + + + + ); +} + +function ShipmentVolumePanel({ range, setRange }: { range: RangeValue; setRange: (value: RangeValue) => void }) { + const totalSea = shipmentVolumeData.reduce((sum, item) => sum + item.sea, 0); + + return ( + `${datum.day}, 2026`} + getXAxisLabel={(datum) => datum.day} + header={ + + + Shipment Volume by Mode + + Carrier flow is broadening across sea, air, and road without pushing the workspace + away from the calmer Cadence control-surface style. + + + + setRange(value as RangeValue)} value={range}> + Today + Weekly + Monthly + + + {formatCompactNumber(totalSea)} + + <> + + +12.6% + + versus last week + + + + + + } + height={340} + legendValueMode="last" + renderTooltip={AnalyticsTooltip} + series={volumeSeries} + showLegend + title="Shipment Volume by Mode" + yAxisValueFormatter={formatCompactNumber} + /> + ); +} + +function BookingMomentumPanel() { + return ( + `${datum.day}, 2026`} + getXAxisLabel={(datum) => datum.day} + header={ + + + Quote to Booking Momentum + + Quote volume is rising ahead of confirmations, which is expected while route + consolidation catches up. + + + + + Yearly lens + + + + } + height={340} + legendValueMode="active" + renderTooltip={AnalyticsTooltip} + series={bookingSeries} + showLegend + title="Quote to Booking Momentum" + yAxisValueFormatter={formatCompactNumber} + /> + ); +} + +function AutomationTrendPanel({ + setWindow, + window +}: { + setWindow: (value: AutomationWindow) => void; + window: AutomationWindow; +}) { + const width = 820; + const height = 280; + const paddingX = 24; + const paddingY = 20; + const usableWidth = width - paddingX * 2; + const usableHeight = height - paddingY * 2; + const xStep = usableWidth / (automatedCurve.length - 1); + + const automatedPoints = automatedCurve.map((value, index) => ({ + x: paddingX + index * xStep, + y: paddingY + value * usableHeight + })); + const manualPoints = manualCurve.map((value, index) => ({ + x: paddingX + index * xStep, + y: paddingY + value * usableHeight + })); + const guideIndex = window === "today" ? 3 : 4; + const guideX = automatedPoints[guideIndex]?.x ?? paddingX; + + return ( + + +
+
+ Automation Rate by Category + + The lower lane stays deliberately human-visible, while high-frequency document and + quote work remains dominated by the automation pass. + +
+
+
+ + + Automated + + + + Manual + +
+ setWindow(value as AutomationWindow)} + value={window} + > + Today + Weekly + +
+
+
+ + +
+
+ {automationLabels.map((label) => ( +
+ {label.label} +
+ ))} +
+ +
+
+ + +
+

+ 0.25 +

+
+
+ + + Automated + + 95 +
+
+ + + Manual + + 5 +
+
+
+ +
+ {automationTicks.map((tick) => ( + {tick} + ))} +
+
+
+ + + ); +} + +function AnalyticsWorkspaceScene() { + const [volumeRange, setVolumeRange] = useState("today"); + const [automationWindow, setAutomationWindow] = useState("today"); + + return ( +
+
+ +
+
+
+
+ + + + + + + + + + + + + + + Logistics workspace + + APAC lanes healthy + + + Analytics & Performance + + A Cadence-flavored take on the supplied operations dashboard reference: same + broad layout, but expressed through our softer Material runtime, warmer tonal + surfaces, and current shared component set. + + + + + + + + + +
+ {kpiCards.map((definition, index) => ( + + ))} +
+ +
+ + +
+ +
+ +
+
+
+ +
+
+ ); +} + +const meta = { + title: "Scenes/Analytics Workspace", + component: AnalyticsWorkspaceScene, + parameters: { + docs: { + description: { + component: + "Analytics Workspace composes the current Cadence UI shell, navigation, stat, chart, and metric primitives into a logistics analytics scene inspired by the supplied reference while keeping the repository's active tonal Material direction." + } + }, + layout: "fullscreen" + }, + tags: ["autodocs"] +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; diff --git a/apps/docs/src/components/chart.stories.tsx b/apps/docs/src/components/chart.stories.tsx new file mode 100644 index 0000000..a4da020 --- /dev/null +++ b/apps/docs/src/components/chart.stories.tsx @@ -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[] = [ + { + 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) { + return ( +
+
+ {activeLabel} +
+
+ {series.map((entry) => ( +
+ + {entry.label} + + {entry.formattedValue} + +
+ ))} +
+
+ ); +} + +function SalesImpactPlayground() { + return ( +
+ `${datum.month} 2025`} + getXAxisLabel={(datum) => datum.month} + header={ + + + Sales Impact + + Use Chart when a workflow needs a compact trend surface with one active comparison + moment instead of a generic analytics embed. + + + + + {formatCurrency(101820)} + + <> + + +84 bps + + from the last month + + + + + + } + height={336} + renderTooltip={SalesImpactTooltip} + series={salesImpactSeries} + title="Sales Impact" + yAxisValueFormatter={formatCompactCurrency} + /> +
+ ); +} + +function ChartStates() { + return ( +
+ `${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} + /> + + ""} + series={salesImpactSeries} + title="Empty state" + /> +
+ ); +} + +function ChartAnatomy() { + return ( +
+
+
+

+ Chart anatomy +

+

+ 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. +

+
+ + `${datum.month} 2025`} + getXAxisLabel={(datum) => datum.month} + renderTooltip={SalesImpactTooltip} + series={salesImpactSeries} + showLegend + title="Contracted slots" + value={formatCurrency(186540)} + yAxisValueFormatter={formatCompactCurrency} + /> + +
+

+ data-slot="header" owns the + header frame, while data-slot="leading"{" "} + and data-slot="aside" let + product code compose a custom title stack or review chip without losing the chart's + slot contract. +

+

+ data-slot="plot" is the + bordered trend surface. data-slot="tooltip"{" "} + stays optional, and data-slot="legend"{" "} + exposes the persistent series labels below the plot. +

+
+
+
+ ); +} + +function ChartAccessibility() { + return ( +
+
+

+ Keyboard review +

+

+ 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. +

+
+ + `${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} + /> +
+ ); +} + +function ChartMotion() { + return ( +
+
+

+ Motion review +

+

+ 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. +

+
+ + +
+ ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: () => +}; + +export const States: Story = { + render: () => +}; + +export const Anatomy: Story = { + render: () => +}; + +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: () => +}; + +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: () => +}; diff --git a/apps/docs/src/components/gauge.stories.tsx b/apps/docs/src/components/gauge.stories.tsx new file mode 100644 index 0000000..e93f444 --- /dev/null +++ b/apps/docs/src/components/gauge.stories.tsx @@ -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 ( +
+ +
+ ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => ( +
+
+

+ Capacity Watch +

+

+ Use Gauge for the current level of a metric, not for task completion. +

+

+ 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. +

+
+
+ +
+
+ ) +}; + +export const Shapes: Story = { + render: () => ( +
+ + + +
+ ) +}; + +export const Anatomy: Story = { + render: () => ( +
+
+
+

+ Gauge anatomy +

+

+ Gauge keeps the public structure small: one canvas, one SVG ring system, one value + plate, and optional framing copy below the visual. +

+
+ +
+
+ +
+
+ +
+

+ data-slot="canvas" owns the + responsive gauge frame, while data-slot="svg"{" "} + holds the radial drawing primitives. +

+

+ data-slot="track" is the full + meter range, and data-slot="indicator"{" "} + is the active measured arc. If a denser dashboard wants calibration marks, opt into + them with tickCount, which + exposes data-slot="tick". +

+

+ data-slot="value", + data-slot="label", and + data-slot="description" + keep center readout and supporting copy stable for theming, docs, and tests. +

+
+
+
+ ) +}; + +function GaugeMotionShowcase() { + const [replayKey, setReplayKey] = useState(0); + + return ( +
+
+
+

+ Motion review +

+

+ Gauge should arrive like an instrument waking up, not a number teleporting in. +

+

+ The indicator sweeps in once, the center value counts up with it, and reduced/static + motion still resolves instantly. +

+
+ +
+
+
+ +
+
+ +
+
+
+ ); +} + +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: () => +}; + +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: () => ( +
+
+

+ Review guidance +

+
+

Choose Gauge for current measurement. Choose Progress for ongoing completion.

+

Always give the meter a concrete label such as Forecast confidence.

+

Keep the numeric readout or description meaningful enough that color is supplemental.

+
+
+
+ +
+
+ ) +}; diff --git a/apps/docs/src/components/grid.stories.tsx b/apps/docs/src/components/grid.stories.tsx new file mode 100644 index 0000000..0d569fc --- /dev/null +++ b/apps/docs/src/components/grid.stories.tsx @@ -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 ( +
+
+

+ {eyebrow} +

+

{title}

+
+

{body}

+
+ ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => ( +
+ + + + + + + + + + + + + + + + + +
+ ) +}; + +export const Responsive: Story = { + render: () => ( +
+

+ Col accepts either a direct span like md={6} or + an object like xl={{ span: 4, offset: 2 }}. Base + layout stays mobile-first, so panels stack by default and only split when the + breakpoint prop applies. +

+ + + + + + + + + + + +
+ ), + 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: () => ( +
+ + +
+

+ `Row` renders `data-slot="root"` and owns the shared gap and alignment contract. +

+
+ + +
+

+ `Col` renders `data-slot="item"` and owns placement through `span`, `offset`, + and breakpoint props. +

+
+ + +
+

+ Consumers can still layer normal surface components inside each grid item. +

+
+ +
+
+ ), + 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: () => ( +
+

+ 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. +

+ + + + + + + + + + + +
+ ), + parameters: { + docs: { + description: { + story: + "Cadence UI intentionally omits visual reordering props here. Responsive layout should not create a second, less accessible reading order." + } + } + } +}; diff --git a/apps/docs/src/components/input-group.stories.tsx b/apps/docs/src/components/input-group.stories.tsx new file mode 100644 index 0000000..e3c93cb --- /dev/null +++ b/apps/docs/src/components/input-group.stories.tsx @@ -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 ( + + ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => ( + + + + + + / + + + + ) +}; + +export const States: Story = { + render: () => ( +
+ + + + + Live + + + + + + + + + + + + + needs domain + + + + + + + + locked + + +
+ ) +}; + +export const Anatomy: Story = { + render: () => ( + + prefix + + suffix + + ), + 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: () => ( + + + + + + + + scope + + + Search lanes, owners, or note fragments before assigning work. + Add a narrower query before applying bulk actions. + + + ), + 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." + } + } + } +}; diff --git a/apps/docs/src/components/metric-card.stories.tsx b/apps/docs/src/components/metric-card.stories.tsx new file mode 100644 index 0000000..c5d6dc1 --- /dev/null +++ b/apps/docs/src/components/metric-card.stories.tsx @@ -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 ; +} + +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 ( + + + + Margin watch + Operational cost reduction + + + 42% + + + + $38,250 + +78 + + + Assisted routing and tighter triage are keeping servicing overhead inside the target + window. + + + + + +
+ Quarter target + $101,820 +
+ +

+ Routing efficiency has stayed inside the guided operating band for six consecutive + cycles. +

+
+ + + + +
+ ); +} + +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; + +export default meta; + +type Story = StoryObj; + +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: () => ( +
+
+

+ Motion review +

+

+ The richer KPI panel should stage its hover response. +

+

+ 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. +

+
+
+ + +
+
+ ) +}; + +export const Layouts: Story = { + render: () => ( +
+ + +
+ ) +}; + +export const Anatomy: Story = { + render: () => ( +
+
+
+

+ Metric card anatomy +

+

+ 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. +

+
+ + + +
+

+ data-slot="eyebrow", + data-slot="label", + data-slot="value", and + data-slot="delta" stay + aligned with StatCard so analytics panels and compact KPI cards share one core contract. +

+

+ data-slot="leading" and + data-slot="aside" keep + header layouts structured, so icons, badges, or next-state chips stop leaking into + story-local flex wrappers. +

+

+ data-slot="media" owns the + chart or visual evidence, while data-slot="footer"{" "} + and data-slot="actions" cover + the follow-up details that should not be improvised ad hoc. +

+
+
+
+ ) +}; + +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: () => ( +
+ + + Forecast confidence + + + 31% + +9.2% + + + The signal is healthy enough for planning, but still worth watching week to week. + + + + +
+ ) +}; + +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: () => ( +
+ + +
+ ) +}; diff --git a/apps/docs/src/components/segmented-control.stories.tsx b/apps/docs/src/components/segmented-control.stories.tsx new file mode 100644 index 0000000..8bfa003 --- /dev/null +++ b/apps/docs/src/components/segmented-control.stories.tsx @@ -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("revenue"); + const activeContent = lensContent[value]; + + return ( +
+
+ setValue(nextValue as RevenueLens)} + value={value} + > + Revenue + Support + Forecast + {includeDisabled ? ( + + Handoff + + ) : null} + +
+ +
+

+ {activeContent.eyebrow} +

+

+ {activeContent.title} +

+

+ {activeContent.body} +

+
+
+ ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: () => +}; + +export const States: Story = { + render: () => +}; + +export const Anatomy: Story = { + render: () => ( +
+
+

+ SegmentedControl anatomy +

+ +
+

+ data-slot="root" carries{" "} + data-orientation so the + segment stack can switch between horizontal and vertical layouts without changing the + item API. +

+

+ data-slot="control" exposes + each segment, while data-state="checked"{" "} + and data-disabled stay stable + for styling and tests. +

+
+
+
+ ) +}; + +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: () => +}; + +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: () => ( +
+
+

+ Accessibility notes +

+
+

Use it for one active choice, not for multi-select filters.

+

Keep labels short so the active segment remains easy to scan at a glance.

+

Do not use it as a substitute for top-level page navigation.

+
+
+
+ +
+
+ ) +}; diff --git a/apps/docs/src/components/sparkbar.stories.tsx b/apps/docs/src/components/sparkbar.stories.tsx new file mode 100644 index 0000000..10b94a0 --- /dev/null +++ b/apps/docs/src/components/sparkbar.stories.tsx @@ -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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => ( +
+ + + Sales growth + + Use Sparkbar when a card needs compact directional evidence without graduating into a + full chart. + + + + + + +
+ ) +}; + +export const States: Story = { + render: () => ( +
+ + + All bars active + + The default case is a fully active sparkbar with no highlighted sub-range. + + + + + + + + + + Highlighted range + + Emphasize a contiguous run when only the recent lift should read as active. + + + + + + + + + + Muted tail + + Prefix emphasis works for KPI cards where the active run is the planned or completed + slice. + + + + + + +
+ ) +}; + +export const Anatomy: Story = { + render: () => ( +
+
+
+

+ Sparkbar anatomy +

+

+ Sparkbar keeps the public structure intentionally tiny: one root, repeated bar slots, + and an optional highlighted run for contiguous emphasis. +

+
+ +
+ +
+ +
+

+ data-slot="root" owns the + compact layout and size/tone/variant state for the whole micro-visual. +

+

+ Each repeated data-slot="bar"{" "} + exposes its index, and highlighted bars opt into{" "} + data-active so tests and theme + overrides can target the emphasized run without custom DOM. +

+
+
+
+ ) +}; + +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: () => ( +
+
+

+ Decorative in a card +

+

+ When the surrounding card already says what the metric means, keep Sparkbar decorative so + assistive tech does not hear duplicated context. +

+
+ +
+
+ +
+

+ Named standalone visual +

+

+ If the sparkbar stands on its own, give it an accessible name so it can be announced as + a self-contained visual summary. +

+
+ +
+
+
+ ) +}; diff --git a/apps/docs/src/components/stat-card.stories.tsx b/apps/docs/src/components/stat-card.stories.tsx new file mode 100644 index 0000000..839f626 --- /dev/null +++ b/apps/docs/src/components/stat-card.stories.tsx @@ -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 ( + + + Revenue pulse + Monthly recurring revenue + + + $101,820 + +8.4% + + + Assisted follow-up and steadier route timing are lifting close quality this month. + + + ); +} + +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; + +export default meta; + +type Story = StoryObj; + +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: () => ( +
+
+

+ Motion review +

+

+ Keep the KPI slab buoyant, not theatrical. +

+

+ 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. +

+
+
+ + + + Pipeline pulse + Qualified expansion forecast + + + 31% + +9.2% + + + The uplift signal is strong enough to justify a wider follow-up wave this week. + + +
+
+ ) +}; + +export const States: Story = { + render: () => ( +
+ + + + Risk watch + Qualified pipeline + + + $82,450 + -3.1% + + + Mid-market deal spread is still wider than the board would like. + + + + + AI influence + Forecast confidence + + + 31% + +9.2% + + + Commercial planning is stable enough to expand the next follow-up wave. + + +
+ ) +}; + +export const Anatomy: Story = { + render: () => ( +
+
+
+

+ Stat card anatomy +

+

+ 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 + MetricCard. +

+
+ + + +
+

+ data-slot="header" groups + the eyebrow and label. +

+

+ data-slot="metric" holds + the headline value and its delta badge. +

+

+ data-slot="value", + data-slot="delta", and + data-slot="description" + keep KPI styling hooks stable for docs and consumers. +

+

+ data-interactive keeps the + hover-ready treatment explicit when the stat should feel more lifted than surrounding + utility panels. +

+
+
+
+ ) +}; + +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: () => ( +
+
+

+ Review guidance +

+
+

Use a label that names the metric, not just a vague business area.

+

Delta should still be readable as text such as +8.4% or -3.1%.

+

Use the chip tone and shape as reinforcement, not as the only signal that the number moved.

+

The description should explain the current signal, not restate the number.

+
+
+
+ +
+
+ ) +}; diff --git a/apps/docs/src/components/value-field.stories.tsx b/apps/docs/src/components/value-field.stories.tsx new file mode 100644 index 0000000..fb96769 --- /dev/null +++ b/apps/docs/src/components/value-field.stories.tsx @@ -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 ( + + ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + render: (args) => ( + + + + + + ORBT-7X92-KLL9-001P + + + + + + Use this code if you cannot scan the QR graphic. + + + ) +}; + +export const States: Story = { + render: () => ( +
+ + prod-eu-central-1 + + + + launch-window-locked + + + + + release + + duplicate + + + + + + ORBT-7X92-KLL9-001P + + + + + +
+ ) +}; + +export const Anatomy: Story = { + render: () => ( + + prefix + data-slot="value" + suffix + + ), + 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: () => ( + + + + + prod-eu-central-1 + + + + + + Keep the value readable and label-connected even when copy actions are present. + + This value is stale and needs regeneration. + + + ), + 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." + } + } + } +}; diff --git a/apps/docs/src/patterns/app-shell.stories.tsx b/apps/docs/src/patterns/app-shell.stories.tsx new file mode 100644 index 0000000..8ee7be1 --- /dev/null +++ b/apps/docs/src/patterns/app-shell.stories.tsx @@ -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 ( + + + + ); +} + +function RailIcon({ kind }: { kind: "overview" | "pipeline" | "team" }) { + switch (kind) { + case "pipeline": + return ( + + ); + case "team": + return ( + + ); + case "overview": + default: + return ( + + ); + } +} + +function AppShellExample() { + return ( +
+ + + + +
+ +
+

+ ScaleOps +

+

Workspace shell

+
+
+
+ + + + Main + + + + + + Overview + + + + + + Pipeline + + + + + + Team + + + + +
+
+ + + + + + Workspace shell + One shared frame for navigation, header, and page body. + + AppShell owns the outer layout contract so product pages can swap content without + reinventing the sidebar and body structure. + + + + + + + + + + +
+ + + Live revenue pulse + + + $101,820 + +84 + + + AI-assisted follow-up is still the strongest lift across the board window. + + + + + + Forecast confidence + + + 31% + +9.2% + + + Strong enough for planning, but still worth watching through the anomaly lane. + + +
+
+ + + + + Shell state is synchronized across the workspace. + + The page footer closes the shell with status and low-emphasis follow-up actions. + + + + + Ready for review + + + + +
+
+
+ ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const Anatomy: Story = { + render: () => ( +
+
+

+ App shell anatomy +

+

+ 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. +

+
+ + +
+ ) +}; + +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: () => ( +
+
+

+ Motion review +

+

+ 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. +

+
+ +
+ ) +}; diff --git a/apps/docs/src/patterns/challenge-progress.stories.tsx b/apps/docs/src/patterns/challenge-progress.stories.tsx new file mode 100644 index 0000000..5b8caef --- /dev/null +++ b/apps/docs/src/patterns/challenge-progress.stories.tsx @@ -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 ( + + ); +} + +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["items"]; +}) { + return } 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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const States: Story = { + render: () => ( +
+ + +
+ ) +}; + +export const Anatomy: Story = { + render: () => ( +
+
+

+ Challenge progress anatomy +

+

+ 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. +

+
+ + + +
+

+ data-slot="header" and{" "} + data-slot="title" own the shared + panel framing. +

+

+ data-slot="list" wraps the + repeated rows, and each data-slot="item"{" "} + exposes data-state and{" "} + data-variant for complete, + active, or indeterminate styling. +

+

+ data-slot="status" groups the + two right-side chips, while data-slot="meter"{" "} + contains the actual segmented Progress{" "} + primitive. +

+

+ data-slot="footer" keeps the + result readout and the right-aligned max label consistent across rows. +

+
+
+ ) +}; + +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: () => ( +
+
+

+ Review guidance +

+
+

Name the progressbar after the thing being measured, not just the phase.

+

Keep the status text explicit, such as Passed, Review, or Queued.

+

Use the bottom result row to restate the numeric outcome so the bar is not the only source of truth.

+

If a row is waiting on external work, a pending chip and indeterminate progress should still make sense without motion.

+
+
+ + +
+ ) +}; diff --git a/apps/docs/src/patterns/page-footer.stories.tsx b/apps/docs/src/patterns/page-footer.stories.tsx new file mode 100644 index 0000000..bb714b8 --- /dev/null +++ b/apps/docs/src/patterns/page-footer.stories.tsx @@ -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 ( +
+ + + + + Board synced + + + Forecast live + + + + Revenue command center is ready for the weekly board review. + + + Assisted routing, forecast activity, and anomaly monitoring are all updated from the + same operating window. + + + + + + + + +
+ ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const Tones: Story = { + render: () => ( +
+ + + +
+ ) +}; + +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: () => ( +
+
+

+ Motion review +

+

+ 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. +

+
+ +
+ ) +}; diff --git a/apps/docs/src/patterns/page-header.stories.tsx b/apps/docs/src/patterns/page-header.stories.tsx new file mode 100644 index 0000000..9e6f8ac --- /dev/null +++ b/apps/docs/src/patterns/page-header.stories.tsx @@ -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 ( +
+ + + + Friday, Dec 5, 2025 + + Calm pulse + + + Welcome back, Nathan. + + 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. + + + + + + + + +
+ ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const Variants: Story = { + render: () => ( +
+ + + +
+ ) +}; + +export const Anatomy: Story = { + render: () => ( +
+
+

+ Page header anatomy +

+

+ 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. +

+
+ + + +
+

+ data-slot="leading" owns the + meta, title, and description stack. +

+

+ data-slot="actions" is reserved + for the page-level control cluster so page scenes stop improvising this split every time. +

+
+
+ ) +}; + +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: () => ( +
+
+

+ Motion review +

+

+ 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. +

+
+ +
+ ) +}; diff --git a/apps/docs/src/patterns/sidebar-nav.stories.tsx b/apps/docs/src/patterns/sidebar-nav.stories.tsx new file mode 100644 index 0000000..32b535a --- /dev/null +++ b/apps/docs/src/patterns/sidebar-nav.stories.tsx @@ -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 ( + + + + ); +} + +function NavIcon({ kind }: { kind: "overview" | "pipeline" | "messages" | "team" | "billing" }) { + switch (kind) { + case "pipeline": + return ( + + ); + case "messages": + return ( + + ); + case "team": + return ( + + ); + case "billing": + return ( + + ); + case "overview": + default: + return ( + + ); + } +} + +function SidebarNavExample() { + return ( + + +
+
+ +
+

+ ScaleOps +

+

+ Revenue command center +

+
+
+ +
+
+ + + + + Main + + + + + + Dashboard + + + + + + Pipeline + + + + + + Messages + + + 2 + + + + + + + + Management + + + + + + Team performance + + + + + + Billing + + + + + + +
+

+ Next pulse +

+

+ Team forecast review at 09:30 +

+

+ Sales, finance, and ops owners are aligned on the board prep. +

+ +
+
+ + ); +} + +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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const Anatomy: Story = { + render: () => ( +
+
+

+ Sidebar nav anatomy +

+

+ 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. +

+
+ + + +
+

+ data-slot="header", + data-slot="content", and + data-slot="footer" define the + rail frame. +

+

+ data-slot="section", + data-slot="item", and the + nested icon / label / badge slots keep + grouped navigation predictable for styling and tests. +

+
+
+ ) +}; diff --git a/apps/docs/src/patterns/two-factor-setup.stories.tsx b/apps/docs/src/patterns/two-factor-setup.stories.tsx new file mode 100644 index 0000000..57741d8 --- /dev/null +++ b/apps/docs/src/patterns/two-factor-setup.stories.tsx @@ -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 ( + + ); +} + +function CornerGuide({ className }: { className?: string }) { + return ( +