Compare commits

..

10 Commits

Author SHA1 Message Date
kurihada f24da6987b fix(ui): align gauge animation with validation
Harness Validate / harness-validate (push) Has started running
Create Release Tag / create-tag (push) Has started running
Release Version PR / version-packages (push) Has started running
2026-03-25 20:00:09 +08:00
kurihada f049736c8a feat(docs): add analytics and pattern showcase stories 2026-03-25 19:59:42 +08:00
kurihada a5d75f42e9 feat(ui): add analytics primitives and layout patterns 2026-03-25 19:49:49 +08:00
kurihada cc1509d2f6 feat(ui): polish core component surfaces 2026-03-25 19:49:15 +08:00
kurihada eccaacece7 chore(docs): stabilize storybook source discovery 2026-03-25 19:48:27 +08:00
kurihada 49f94a6c89 chore: add opencode agent defaults
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-24 18:34:56 +08:00
kurihada 05919a7719 docs: refresh harness contributor commands
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-24 18:34:56 +08:00
kurihada 800db360b3 docs(harness): document control plane workflow
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-24 18:34:56 +08:00
kurihada 704cacfd8d feat(harness): add orchestration plan helpers
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-24 18:34:56 +08:00
kurihada fe24dfba68 feat(harness): centralize browser coverage contract
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-24 18:34:56 +08:00
166 changed files with 17350 additions and 1703 deletions
+2
View File
@@ -12,3 +12,5 @@ playwright-report
test-results
output
.DS_Store
.playwright-cli
Library
+72
View File
@@ -0,0 +1,72 @@
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"sisyphus": {
"model": "openai/gpt-5.4",
"variant": "xhigh"
},
"hephaestus": {
"model": "openai/gpt-5.4",
"variant": "medium"
},
"oracle": {
"model": "openai/gpt-5.4",
"variant": "high"
},
"explore": {
"model": "openai/gpt-5.4"
},
"multimodal-looker": {
"model": "openai/gpt-5.4",
"variant": "medium"
},
"prometheus": {
"model": "openai/gpt-5.4",
"variant": "xhigh"
},
"metis": {
"model": "openai/gpt-5.4",
"variant": "xhigh"
},
"momus": {
"model": "openai/gpt-5.4",
"variant": "xhigh"
},
"atlas": {
"model": "openai/gpt-5.4"
},
"sisyphus-junior": {
"model": "openai/gpt-5.4"
}
},
"categories": {
"visual-engineering": {
"model": "openai/gpt-5.4",
"variant": "high"
},
"ultrabrain": {
"model": "openai/gpt-5.4",
"variant": "xhigh"
},
"deep": {
"model": "openai/gpt-5.4",
"variant": "medium"
},
"artistry": {
"model": "openai/gpt-5.4",
"variant": "high"
},
"quick": {
"model": "openai/gpt-5.4"
},
"unspecified-low": {
"model": "openai/gpt-5.4"
},
"unspecified-high": {
"model": "openai/gpt-5.4"
},
"writing": {
"model": "openai/gpt-5.4"
}
}
}
+5
View File
@@ -73,6 +73,9 @@ Minimum story recipe:
- `Anatomy`: document stable slots and public `data-*` hooks
- `Accessibility` or `Motion`: choose whichever behavior is easiest to misunderstand
If a story becomes part of the curated browser review surface, add or update its entry in
`tests/e2e/support/story-harness-contract.json` so smoke and a11y coverage stay aligned.
Writing rules:
- Use `docs.description.component` to explain when to choose the component.
@@ -117,6 +120,7 @@ Harness shortcuts:
pnpm harness:select
pnpm harness:validate:static
pnpm harness:validate:changed
pnpm harness:validate:a11y
pnpm harness:validate:component
pnpm harness:validate:docs
pnpm harness:validate:consumers
@@ -135,6 +139,7 @@ Broader gates:
```bash
pnpm harness:validate:pr
pnpm harness:validate:release
pnpm harness:orch -- doctor
```
## Practical repo guidance
+4 -1
View File
@@ -15,7 +15,7 @@ default styling with its own tokens, motion recipes, and component contract.
## Current status
- The foundation, token layer, authoring contract, Storybook docs, and unit coverage are in place.
- The public UI surface now includes the core form and overlay set plus advanced patterns such as `DataTable`, `Command`, `Combobox`, `Sheet`, and `EmptyState`.
- The public UI surface now includes the core form and overlay set plus advanced patterns such as `Chart`, `DataTable`, `Command`, `Combobox`, `Sheet`, and `EmptyState`.
- The default distribution path is package-first: `@ai-ui/ui` and `@ai-ui/tokens` are versioned and validated for package consumption.
- The internal source-copy registry flow remains available as an optional mode for teams that want local ownership of copied component source.
- The active visual direction is documented in [DESIGN.md](/Users/xd/project/cadence-ui/DESIGN.md): a single Material You inspired language with dynamic color, tonal surfaces, large radii, and one shared motion system.
@@ -180,6 +180,7 @@ uses:
- Storybook interaction coverage for representative examples
- Playwright smoke coverage for core Storybook flows
- Storybook a11y checks as part of the docs review surface
- a shared `tests/e2e/support/story-harness-contract.json` manifest for curated browser coverage
## Harness engineering
@@ -200,12 +201,14 @@ pnpm harness:select
pnpm harness:suites
pnpm harness:validate:static
pnpm harness:validate:changed
pnpm harness:validate:a11y
pnpm harness:validate:component
pnpm harness:validate:docs
pnpm harness:validate:docs-smoke
pnpm harness:validate:consumers
pnpm harness:validate:pr
pnpm harness:validate:release
pnpm harness:orch -- doctor
pnpm harness:orch -- status --run <run-id>
```
+76 -3
View File
@@ -1,3 +1,5 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -6,9 +8,52 @@ import type { StorybookConfig } from "@storybook/react-vite";
import { mergeConfig } from "vite";
const storybookConfigDir = path.dirname(fileURLToPath(import.meta.url));
const storybookCacheDir = path.resolve(
const repoRoot = path.resolve(storybookConfigDir, "../../..");
const docsSourceDir = path.resolve(storybookConfigDir, "../src");
const storybookRootCacheDir = path.resolve(
storybookConfigDir,
"../../../.artifacts/cache/storybook/vite"
"../../../.artifacts/cache/storybook"
);
const storyFilePattern = /\.stories\.(ts|tsx)$/;
function collectStoryPaths(directory: string): string[] {
const entries = fs.readdirSync(directory, { withFileTypes: true });
const storyPaths: string[] = [];
for (const entry of entries) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
storyPaths.push(...collectStoryPaths(entryPath));
continue;
}
if (storyFilePattern.test(entry.name)) {
storyPaths.push(path.relative(docsSourceDir, entryPath).split(path.sep).join("/"));
}
}
return storyPaths;
}
function createStorybookCacheKey() {
const storyPaths = collectStoryPaths(docsSourceDir).sort();
return crypto
.createHash("sha1")
.update(JSON.stringify(storyPaths))
.digest("hex")
.slice(0, 8);
}
const storybookCacheDir = path.resolve(
storybookRootCacheDir,
`vite-${createStorybookCacheKey()}`
);
const uiSourceEntry = path.resolve(storybookConfigDir, "../../../packages/ui/src/index.ts");
const tokensSourceEntry = path.resolve(
storybookConfigDir,
"../../../packages/tokens/src/index.ts"
);
const config: StorybookConfig = {
@@ -25,7 +70,35 @@ const config: StorybookConfig = {
async viteFinal(config) {
return mergeConfig(config, {
cacheDir: storybookCacheDir,
plugins: [tailwindcss()]
plugins: [tailwindcss()],
optimizeDeps: {
exclude: ["@ai-ui/ui", "@ai-ui/tokens"]
},
resolve: {
alias: [
{
find: /^@ai-ui\/ui$/,
replacement: uiSourceEntry
},
{
find: /^@ai-ui\/tokens$/,
replacement: tokensSourceEntry
}
]
},
server: {
fs: {
allow: [repoRoot]
},
watch: {
usePolling: true,
interval: 120,
awaitWriteFinish: {
stabilityThreshold: 80,
pollInterval: 40
}
}
}
});
}
};
+6 -1
View File
@@ -58,7 +58,12 @@ const preview: Preview = {
controls: {
expanded: true
},
layout: "fullscreen"
layout: "fullscreen",
options: {
storySort: {
order: ["Scenes", "Patterns", "Components", "Foundation"]
}
}
},
decorators: [
(Story, context) => {
+2 -2
View File
@@ -4,8 +4,8 @@
"type": "module",
"scripts": {
"build-storybook": "node ../../scripts/harness/build-storybook.mjs",
"storybook": "WATCHPACK_POLLING=true CHOKIDAR_USEPOLLING=true storybook dev -p 6006 --ci --disable-telemetry",
"storybook:smoke": "WATCHPACK_POLLING=true CHOKIDAR_USEPOLLING=true storybook dev -p 6006 --ci --smoke-test --disable-telemetry",
"storybook": "storybook dev -p 6006 --disable-telemetry",
"storybook:smoke": "storybook dev -p 6006 --ci --smoke-test --disable-telemetry",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
File diff suppressed because it is too large Load Diff
@@ -31,6 +31,12 @@ const meta = {
}
},
parameters: {
docs: {
description: {
component:
"Alert is the inline callout surface for status, guidance, and blocking information that should stay inside the current page flow. It now enters with a restrained rise/fade and uses a softly plated icon treatment so state changes feel present without turning the component into a hero panel."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -30,6 +30,12 @@ const meta = {
}
},
parameters: {
docs: {
description: {
component:
"Avatar is the compact identity surface for people, teams, and owners. The treatment stays quiet, but the image now resolves over the fallback with a soft crossfade so identity changes feel polished instead of abrupt."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -34,6 +34,12 @@ const meta = {
}
},
parameters: {
docs: {
description: {
component:
"Badge is the smallest status and chip surface in the system. It still reads as a compact token, but subtle and outline badges now carry a lighter tactile response so filter-like and live-status usage stops feeling mechanically flat."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -70,3 +76,40 @@ export const Matrix: Story = {
</div>
)
};
export const LiveAndFilters: Story = {
parameters: {
docs: {
description: {
story:
"Use `Badge` as a small status token by default, and as a tactile chip when it is rendered through `asChild` on an actual interactive element. The motion should stay restrained and chip-like, not button-heavy."
}
}
},
render: () => (
<div className="grid w-[720px] gap-4">
<div className="flex flex-wrap items-center gap-3">
<Badge tone="success">Live</Badge>
<Badge tone="warning">Reviewing</Badge>
<Badge tone="primary">Forecast</Badge>
</div>
<div className="flex flex-wrap items-center gap-3">
<Badge asChild tone="primary" variant="outline">
<button aria-pressed="true" type="button">
Design
</button>
</Badge>
<Badge asChild tone="neutral" variant="subtle">
<button aria-pressed="false" type="button">
Editorial
</button>
</Badge>
<Badge asChild tone="success" variant="outline">
<button aria-pressed="false" type="button">
Quiet queue
</button>
</Badge>
</div>
</div>
)
};
+62 -9
View File
@@ -1,4 +1,12 @@
import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@ai-ui/ui";
import {
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
const meta = {
@@ -6,7 +14,7 @@ const meta = {
component: Card,
args: {
tone: "default",
interactive: false
interactive: true
},
argTypes: {
className: {
@@ -21,6 +29,12 @@ const meta = {
}
},
parameters: {
docs: {
description: {
component:
"Card is the base slab surface for panels, compact workflows, and supporting information blocks. It now defaults to a gentle interactive lift so common business cards feel alive by default, while `interactive={false}` remains available for deliberately static surfaces."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -34,22 +48,61 @@ export const Playground: Story = {
render: (args) => (
<Card {...args} className="w-[420px]">
<CardHeader>
<CardTitle>Release card</CardTitle>
<CardDescription>Summarize state, ownership, and next action.</CardDescription>
<CardTitle>Routing overview</CardTitle>
<CardDescription>Keep status, ownership, and the next action in one calm surface.</CardDescription>
</CardHeader>
<CardContent>
This surface is tuned for editorial dashboards and settings views.
This base card now carries a light hover lift by default so common workflow slabs do not
feel inert.
</CardContent>
<CardFooter>
<Button size="sm">Open</Button>
<Button size="sm">Open queue</Button>
<Button size="sm" variant="ghost">
Dismiss
Snooze
</Button>
</CardFooter>
</Card>
)
};
export const States: Story = {
parameters: {
docs: {
description: {
story:
"Use the default interactive treatment for navigable or actionable slabs, and opt out only when the card is meant to behave like a static document section."
}
}
},
render: () => (
<div className="grid w-[920px] gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Default interactive</CardTitle>
<CardDescription>Gentle lift and internal light cue for most business cards.</CardDescription>
</CardHeader>
<CardContent>Good for dashboards, queues, and next-step summaries.</CardContent>
</Card>
<Card interactive={false} tone="subtle">
<CardHeader>
<CardTitle>Static document slab</CardTitle>
<CardDescription>Opt out when the surface should read as anchored reference content.</CardDescription>
</CardHeader>
<CardContent>Useful for long-form notes, legal copy, and passive explanations.</CardContent>
</Card>
<Card tone="accent">
<CardHeader>
<CardTitle>Accent emphasis</CardTitle>
<CardDescription>Carry the same motion language while keeping the tonal emphasis richer.</CardDescription>
</CardHeader>
<CardContent>Use when the card itself is part of the call to action.</CardContent>
</Card>
</div>
)
};
export const Grid: Story = {
render: () => (
<div className="relative grid w-[940px] gap-6 overflow-hidden rounded-[2.3rem] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_82%,white_18%),color-mix(in_oklch,var(--color-background)_88%,white_12%))] p-6 shadow-[0_24px_72px_color-mix(in_oklch,var(--color-primary)_10%,transparent)] md:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
@@ -80,11 +133,11 @@ export const Grid: Story = {
<Card className="motion-float">
<CardHeader>
<CardTitle>Default tone</CardTitle>
<CardDescription>Standard elevated panel for data and form sections.</CardDescription>
<CardDescription>Standard elevated panel with the new default hover cue.</CardDescription>
</CardHeader>
<CardContent>Reliable baseline for most admin surfaces.</CardContent>
</Card>
<Card className="motion-float-delayed justify-self-end md:w-[88%]" interactive tone="accent">
<Card className="motion-float-delayed justify-self-end md:w-[88%]" tone="accent">
<CardHeader>
<CardTitle>Interactive accent</CardTitle>
<CardDescription>Hover-capable treatment for navigable cards.</CardDescription>
+346
View File
@@ -0,0 +1,346 @@
import {
Chart,
ChartChange,
ChartDescription,
ChartHeader,
ChartHeaderAside,
ChartHeaderLeading,
ChartMetrics,
ChartTitle,
ChartValue,
type ChartSeries,
type ChartTooltipContext
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
type SalesImpactDatum = {
gross: number;
month: string;
revenue: number;
};
const salesImpactData: SalesImpactDatum[] = [
{ gross: 52450, month: "Jan", revenue: 71280 },
{ gross: 112400, month: "Feb", revenue: 92320 },
{ gross: 98420, month: "Mar", revenue: 138260 },
{ gross: 152800, month: "Apr", revenue: 165420 },
{ gross: 82450, month: "May", revenue: 125320 },
{ gross: 136200, month: "Jun", revenue: 186540 },
{ gross: 118320, month: "Jul", revenue: 172480 },
{ gross: 121560, month: "Aug", revenue: 149860 },
{ gross: 109440, month: "Sep", revenue: 143220 },
{ gross: 141880, month: "Oct", revenue: 176340 },
{ gross: 132760, month: "Nov", revenue: 167920 }
];
const compactCurrencyFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 1,
notation: "compact",
style: "currency"
});
const currencyFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 0,
style: "currency"
});
function formatCurrency(value: number) {
return currencyFormatter.format(value);
}
function formatCompactCurrency(value: number) {
return compactCurrencyFormatter.format(value);
}
const salesImpactSeries: ChartSeries<SalesImpactDatum>[] = [
{
getValue: (datum) => datum.revenue,
id: "revenue",
label: "Revenue",
style: "line-area",
tone: "primary",
valueFormatter: formatCurrency
},
{
getValue: (datum) => datum.gross,
id: "gross",
label: "Gross",
strokeDasharray: "6 8",
style: "line",
tone: "neutral",
valueFormatter: formatCurrency
}
];
function SalesImpactTooltip({
activeLabel,
series
}: ChartTooltipContext<SalesImpactDatum>) {
return (
<div className="grid gap-3">
<div className="text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
{activeLabel}
</div>
<div className="grid gap-2">
{series.map((entry) => (
<div
className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 text-sm"
key={entry.id}
>
<span
className="size-2.5 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-[var(--color-muted-foreground)]">{entry.label}</span>
<span className="font-medium text-[var(--color-foreground)]">
{entry.formattedValue}
</span>
</div>
))}
</div>
</div>
);
}
function SalesImpactPlayground() {
return (
<div className="w-full max-w-[960px]">
<Chart
data={salesImpactData}
defaultActiveIndex={4}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
header={
<ChartHeader>
<ChartHeaderLeading>
<ChartTitle>Sales Impact</ChartTitle>
<ChartDescription>
Use Chart when a workflow needs a compact trend surface with one active comparison
moment instead of a generic analytics embed.
</ChartDescription>
</ChartHeaderLeading>
<ChartHeaderAside>
<ChartMetrics>
<ChartValue>{formatCurrency(101820)}</ChartValue>
<ChartChange>
<>
<span className="inline-flex items-center rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--color-success)_14%,white_86%)] px-2.5 py-1 text-[color-mix(in_oklch,var(--color-success)_72%,var(--color-foreground))]">
+84 bps
</span>
<span>from the last month</span>
</>
</ChartChange>
</ChartMetrics>
</ChartHeaderAside>
</ChartHeader>
}
height={336}
renderTooltip={SalesImpactTooltip}
series={salesImpactSeries}
title="Sales Impact"
yAxisValueFormatter={formatCompactCurrency}
/>
</div>
);
}
function ChartStates() {
return (
<div className="grid w-full max-w-[1040px] gap-4 lg:grid-cols-2">
<Chart
data={salesImpactData}
defaultActiveIndex={7}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
legendValueMode="active"
series={[
salesImpactSeries[0],
{
...salesImpactSeries[1],
tone: "success"
}
]}
showLegend
title="Active comparison"
value={formatCurrency(149860)}
valueChange="Legend values follow the active point when the chart is interactive."
yAxisValueFormatter={formatCompactCurrency}
/>
<Chart
data={[] as SalesImpactDatum[]}
empty="Keep the surrounding card chrome and swap the plot for an explicit empty message instead of collapsing the whole panel."
getXAxisLabel={() => ""}
series={salesImpactSeries}
title="Empty state"
/>
</div>
);
}
function ChartAnatomy() {
return (
<div className="w-full max-w-[960px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-5">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Chart anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
The stable review hooks are the header, plot, tooltip, and legend slots. The chart is
intentionally narrow: one cartesian trend surface with optional active comparison and
legend values.
</p>
</div>
<Chart
className="shadow-none"
data={salesImpactData}
defaultActiveIndex={5}
description="Use the tooltip slot for the floating active summary and the legend slot for persistent series labels or values."
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
renderTooltip={SalesImpactTooltip}
series={salesImpactSeries}
showLegend
title="Contracted slots"
value={formatCurrency(186540)}
yAxisValueFormatter={formatCompactCurrency}
/>
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="header"</code> owns the
header frame, while <code className="text-[var(--color-foreground)]">data-slot="leading"</code>{" "}
and <code className="text-[var(--color-foreground)]">data-slot="aside"</code> let
product code compose a custom title stack or review chip without losing the chart's
slot contract.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="plot"</code> is the
bordered trend surface. <code className="text-[var(--color-foreground)]">data-slot="tooltip"</code>{" "}
stays optional, and <code className="text-[var(--color-foreground)]">data-slot="legend"</code>{" "}
exposes the persistent series labels below the plot.
</p>
</div>
</div>
</div>
);
}
function ChartAccessibility() {
return (
<div className="grid w-full max-w-[960px] gap-4">
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-5 shadow-[var(--shadow-sm)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Keyboard review
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Each x-position is backed by a focusable hit target, so the active point summary works on
hover and on keyboard focus. Tab across the chart to move the tooltip and legend without
relying on pointer-only interaction.
</p>
</div>
<Chart
data={salesImpactData}
defaultActiveIndex={3}
getActiveLabel={(datum) => `${datum.month} 2025`}
getPointAriaLabel={({ activeLabel, series }) =>
`${activeLabel}. ${series.map((entry) => `${entry.label} ${entry.formattedValue}`).join(". ")}.`
}
getXAxisLabel={(datum) => datum.month}
renderTooltip={SalesImpactTooltip}
series={[
salesImpactSeries[0],
{
...salesImpactSeries[1],
tone: "success"
}
]}
showLegend
title="Focusable active points"
value={formatCurrency(165420)}
valueChange="Hover or tab through the plot to move the active summary."
yAxisValueFormatter={formatCompactCurrency}
/>
</div>
);
}
function ChartMotion() {
return (
<div className="grid w-full max-w-[960px] gap-4">
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-5 shadow-[var(--shadow-sm)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Motion review
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Move across the plot and watch for three coordinated cues: the active guide should fade
to the next position, the point markers should bloom instead of snapping, and the tooltip
card should rise in with a short 120-180ms transition. The motion should stay calm enough
that the chart still reads like a dashboard surface, not a demo toy.
</p>
</div>
<SalesImpactPlayground />
</div>
);
}
const meta = {
title: "Components/Chart",
component: SalesImpactPlayground,
parameters: {
docs: {
description: {
component:
"Chart is Cadence UI's dashboard trend surface for compact multi-series analytics panels. Use it when a product workflow needs a source-owned line chart with an active comparison state, soft area treatment, and stable slots for header, tooltip, and legend content."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof SalesImpactPlayground>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => <SalesImpactPlayground />
};
export const States: Story = {
render: () => <ChartStates />
};
export const Anatomy: Story = {
render: () => <ChartAnatomy />
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Chart motion is limited to the active comparison layer. The plot itself stays stable while the guide, marker, and tooltip transition together."
}
}
},
render: () => <ChartMotion />
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"The rendered SVG is decorative; the reviewable interaction contract comes from focusable hit targets layered over each x-position. That keeps the active comparison usable with a keyboard while avoiding pointer-only hover state."
}
}
},
render: () => <ChartAccessibility />
};
@@ -222,6 +222,12 @@ const meta = {
title: "Components/Combobox",
component: ControlledDemo,
parameters: {
docs: {
description: {
component:
"Combobox is the searchable single-select surface for longer option sets, recent picks, and async lookup flows. The list should feel gently staged as results appear, with active rows gliding into place instead of snapping."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -240,6 +246,14 @@ export const RecentAndSuggested: Story = {
};
export const AsyncResults: Story = {
parameters: {
docs: {
description: {
story:
"Async comboboxes should crossfade between loading, empty, and result states. Keep the motion short and directional so the user understands the state change without losing the current context."
}
}
},
render: () => <AsyncResultsDemo />
};
+114 -10
View File
@@ -42,7 +42,18 @@ function InlineCommandShowcase() {
<CommandGroup heading={group.heading}>
{group.items.map((item) => (
<CommandItem key={item.value} value={item.value}>
{item.label}
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">{item.label}</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
{item.value === "open-docs"
? "Jump into authoring guidance, slots, and motion references."
: item.value === "go-releases"
? "Open the current release desk and recent launch notes."
: item.value === "publish-update"
? "Queue the current workspace update for approval."
: "Assign the next reviewer without leaving the palette."}
</span>
</span>
<CommandShortcut>{item.shortcut}</CommandShortcut>
</CommandItem>
))}
@@ -77,22 +88,42 @@ function OperationsWorkbenchShowcase() {
<CommandEmpty>No matching actions.</CommandEmpty>
<CommandGroup heading="Recent">
<CommandItem keywords={["launch", "release"]} value="recent-launch-review">
Launch review
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Launch review</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Reopen the last routed launch brief and approvals queue.
</span>
</span>
<CommandShortcut>R</CommandShortcut>
</CommandItem>
<CommandItem keywords={["blocked", "queue"]} value="recent-blocked-queue">
Blocked queue
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Blocked queue</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Inspect stalled items, owners, and next unblock steps.
</span>
</span>
<CommandShortcut>B</CommandShortcut>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Suggestions">
<CommandItem keywords={["docs", "stories"]} value="docs-storybook">
Open Storybook docs
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Open Storybook docs</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Review the live component contract and interaction stories.
</span>
</span>
<CommandShortcut>G D</CommandShortcut>
</CommandItem>
<CommandItem disabled value="compliance-review">
Compliance review locked
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Compliance review locked</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
This action unlocks after policy artifacts finish syncing.
</span>
</span>
<CommandShortcut>Locked</CommandShortcut>
</CommandItem>
</CommandGroup>
@@ -127,18 +158,49 @@ function DialogCommandShowcase() {
<CommandList>
<CommandEmpty>No commands available.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem value="launch-checklist">Launch checklist</CommandItem>
<CommandItem value="rollout-audit">Rollout audit</CommandItem>
<CommandItem value="brand-theme">Brand theme tokens</CommandItem>
<CommandItem value="launch-checklist">
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Launch checklist</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Open the current milestone handoff and owner checklist.
</span>
</span>
</CommandItem>
<CommandItem value="rollout-audit">
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Rollout audit</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Review timing, communications, and incident readiness.
</span>
</span>
</CommandItem>
<CommandItem value="brand-theme">
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Brand theme tokens</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Jump to the token surface that controls tonal palette shifts.
</span>
</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="People">
<CommandItem value="jordan-lee">
Jordan Lee
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Jordan Lee</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Product lead for launch sequencing and review coordination.
</span>
</span>
<CommandShortcut>@</CommandShortcut>
</CommandItem>
<CommandItem value="avery-carter">
Avery Carter
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Avery Carter</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Owns release notes, docs framing, and approval summaries.
</span>
</span>
<CommandShortcut>@</CommandShortcut>
</CommandItem>
</CommandGroup>
@@ -152,6 +214,16 @@ function LoadingResultsShowcase() {
return (
<Command
className="w-[520px]"
footer={
<div className="flex items-center justify-between gap-3">
<p className="m-0 text-xs text-[var(--color-muted-foreground)]">
Cached suggestions stay visible while the workspace search refreshes.
</p>
<Button size="sm" variant="ghost">
View queue
</Button>
</div>
}
label="Remote workspace search"
loading
loadingMessage="Searching remote workspace actions…"
@@ -159,6 +231,24 @@ function LoadingResultsShowcase() {
<CommandInput placeholder="Search actions from all workspaces" />
<CommandList>
<CommandEmpty>No commands available.</CommandEmpty>
<CommandGroup heading="Cached suggestions">
<CommandItem value="cached-launch-checklist">
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Launch checklist</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Reopen the previous launch checklist while fresh results stream in.
</span>
</span>
</CommandItem>
<CommandItem value="cached-review-queue">
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium">Review queue</span>
<span className="text-xs leading-5 text-[var(--color-muted-foreground)]">
Keep the last known reviewer list visible during the remote refresh.
</span>
</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
);
@@ -168,6 +258,12 @@ const meta = {
title: "Components/Command",
component: InlineCommandShowcase,
parameters: {
docs: {
description: {
component:
"Command is the system surface for in-app palettes and quick action search. The highlighted row should feel adhesive and spatial, with enough motion to clarify focus and loading state without turning the palette into a busy animation surface."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -190,5 +286,13 @@ export const DialogPalette: Story = {
};
export const LoadingResults: Story = {
parameters: {
docs: {
description: {
story:
"Loading should not feel like a hard swap. Keep a soft status band at the top, let cached results dim underneath, and preserve the same highlighted-row motion language."
}
}
},
render: () => <LoadingResultsShowcase />
};
+17 -17
View File
@@ -189,15 +189,15 @@ const routingColumns: DataTableColumn<RoutingLaneRow>[] = [
function SignalGlyph() {
return (
<div className="grid gap-2">
<div className="grid grid-cols-[0.8rem_2.8rem_1.5rem] gap-2">
<span className="h-3 rounded-[var(--radius-full)] bg-[var(--color-primary)]" />
<span className="h-3 rounded-[var(--radius-full)] bg-[var(--color-surface-strong)]" />
<span className="h-3 rounded-[var(--radius-full)] bg-[var(--color-accent)]" />
<div className="grid gap-3">
<div className="grid grid-cols-[0.9rem_3.1rem_1.75rem] gap-2">
<span className="h-3.5 rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--color-primary)_88%,white_12%)]" />
<span className="h-3.5 rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--color-surface-strong)_82%,white_18%)]" />
<span className="h-3.5 rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--color-accent)_82%,white_18%)]" />
</div>
<div className="grid gap-2">
<span className="h-2 rounded-[var(--radius-full)] bg-[var(--color-border-strong)]" />
<span className="h-2 w-16 rounded-[var(--radius-full)] bg-[var(--color-border)]" />
<span className="h-2.5 rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--color-border-strong)_78%,white_22%)]" />
<span className="h-2.5 w-16 rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--color-border)_88%,white_12%)]" />
</div>
</div>
);
@@ -244,7 +244,7 @@ function RoutingEmptyState({ onReset }: { onReset: () => void }) {
function RoutingLaneDetail({ row }: { row: RoutingLaneRow }) {
return (
<div className="grid gap-4">
<div className="grid gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-xs)]">
<div className="grid gap-3 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-container-low)_82%,white_18%),color-mix(in_oklch,var(--color-surface)_90%,white_10%))] p-4 shadow-[var(--shadow-sm)]">
<div className="flex flex-wrap items-center gap-2">
<Badge size="sm" tone={getStateTone(row.state)} variant="outline">
{row.state}
@@ -260,14 +260,14 @@ function RoutingLaneDetail({ row }: { row: RoutingLaneRow }) {
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
<div className="rounded-[var(--radius-lg)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-background)_86%,white_14%),color-mix(in_oklch,var(--color-surface-container-low)_82%,white_18%))] p-4 shadow-[var(--shadow-xs)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Owner
</p>
<p className="mt-2 text-sm font-medium text-[var(--color-foreground)]">{row.owner}</p>
<p className="mt-1 text-sm text-[var(--color-muted-foreground)]">{row.ownerEmail}</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
<div className="rounded-[var(--radius-lg)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-background)_86%,white_14%),color-mix(in_oklch,var(--color-surface-container-low)_82%,white_18%))] p-4 shadow-[var(--shadow-xs)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Next gate
</p>
@@ -307,8 +307,8 @@ function DataTablePlayground() {
}
return (
<div className="grid gap-4 text-[var(--color-foreground)]">
<div className="grid gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)] lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="grid gap-5 text-[var(--color-foreground)]">
<div className="grid gap-3 rounded-[calc(var(--radius-xl)+0.2rem)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_82%,white_18%),color-mix(in_oklch,var(--color-surface-container-low)_78%,white_22%))] p-5 shadow-[var(--shadow-sm)] lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Standalone workflow
@@ -321,7 +321,7 @@ function DataTablePlayground() {
exercising built-in search, sorting, pagination, selection, and toolbar actions.
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] px-4 py-3">
<div className="rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-primary)_14%,var(--color-border))] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_52%,white_48%),color-mix(in_oklch,var(--color-background)_84%,white_16%))] px-4 py-3 shadow-[var(--shadow-xs)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Last action
</p>
@@ -329,7 +329,7 @@ function DataTablePlayground() {
</div>
</div>
<div className="w-full max-w-[1120px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)]">
<div className="w-full max-w-[1120px] rounded-[calc(var(--radius-xl)+0.2rem)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_76%,white_24%),color-mix(in_oklch,var(--color-surface-container-low)_88%,white_12%))] p-4 shadow-[var(--shadow-sm)]">
<DataTable
columns={routingColumns}
defaultDensity="comfortable"
@@ -430,7 +430,7 @@ function DataTablePlayground() {
function DataTableLoadingState() {
return (
<div className="w-full max-w-[1120px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)]">
<div className="w-full max-w-[1120px] rounded-[calc(var(--radius-xl)+0.2rem)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_76%,white_24%),color-mix(in_oklch,var(--color-surface-container-low)_88%,white_12%))] p-4 shadow-[var(--shadow-sm)]">
<DataTable
columns={routingColumns}
getRowId={(row) => row.id}
@@ -448,7 +448,7 @@ function DataTableLoadingState() {
function DataTableEmptyStateDemo() {
return (
<div className="w-full max-w-[1120px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-4 shadow-[var(--shadow-sm)]">
<div className="w-full max-w-[1120px] rounded-[calc(var(--radius-xl)+0.2rem)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_76%,white_24%),color-mix(in_oklch,var(--color-surface-container-low)_88%,white_12%))] p-4 shadow-[var(--shadow-sm)]">
<DataTable
columns={routingColumns}
empty={<RoutingEmptyState onReset={() => undefined} />}
@@ -475,7 +475,7 @@ const meta = {
docs: {
description: {
component:
"A production-style table primitive with built-in search, sorting, pagination, row selection, and external toolbar actions. This standalone page isolates the component from the larger workspace scene while keeping a realistic routing-desk narrative."
"A production-style table primitive with built-in search, sorting, pagination, row selection, and external toolbar actions. This standalone page isolates the component from the larger workspace scene while keeping a realistic routing-desk narrative. Motion should stay calm: rows deepen slightly on hover, sort feedback stays crisp, selected surfaces feel staged instead of loud, and the surrounding control chrome stays mostly anchored instead of all lifting at once."
}
},
layout: "padded"
@@ -9,13 +9,24 @@ import {
EmptyStateTitle
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
import type { ReactNode } from "react";
function EmptyStateGlyph() {
return (
<div className="grid gap-2">
<div className="grid grid-cols-[1.3rem_3.4rem] gap-2">
<span className="h-5 rounded-[var(--radius-sm)] bg-[color-mix(in_oklch,var(--color-primary)_18%,var(--color-card))]" />
<span className="h-5 rounded-[var(--radius-sm)] bg-[var(--color-surface-strong)]" />
<div className="grid gap-3">
<div className="grid grid-cols-[1.1rem_3.6rem] items-center gap-2">
<span className="size-4 rounded-full bg-[color-mix(in_oklch,var(--color-primary)_22%,var(--color-card))] shadow-[0_0_0_4px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]" />
<span className="h-4 rounded-[var(--radius-sm)] bg-[color-mix(in_oklch,var(--color-surface-strong)_86%,white_14%)]" />
</div>
<div className="grid gap-2 rounded-[1rem] border border-[color-mix(in_oklch,var(--color-border)_70%,transparent)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_72%,white_28%)] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]">
<div className="grid grid-cols-[1.2rem_3.4rem] gap-2">
<span className="h-4 rounded-[var(--radius-sm)] bg-[color-mix(in_oklch,var(--color-tertiary-container)_60%,white_40%)]" />
<span className="h-4 rounded-[var(--radius-sm)] bg-[color-mix(in_oklch,var(--color-surface-strong)_80%,white_20%)]" />
</div>
<div className="grid grid-cols-[4.8rem] gap-2">
<span className="h-2 rounded-[var(--radius-full)] bg-[var(--color-border-strong)]" />
<span className="h-2 rounded-[var(--radius-full)] bg-[var(--color-border)]" />
</div>
</div>
<div className="grid grid-cols-[4.8rem] gap-2">
<span className="h-2 rounded-[var(--radius-full)] bg-[var(--color-border-strong)]" />
@@ -25,11 +36,43 @@ function EmptyStateGlyph() {
);
}
function WorkspaceSetupGlyph() {
return (
<div className="grid w-full gap-3">
<div className="flex items-center justify-between gap-3 rounded-[1rem] border border-[color-mix(in_oklch,var(--color-border)_70%,transparent)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_72%,white_28%)] px-3 py-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]">
<div className="grid gap-1.5">
<span className="h-2.5 w-20 rounded-full bg-[color-mix(in_oklch,var(--color-primary)_20%,var(--color-card))]" />
<span className="h-2.5 w-12 rounded-full bg-[var(--color-border)]" />
</div>
<span className="grid size-10 place-items-center rounded-[1rem] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_76%,white_24%),color-mix(in_oklch,var(--color-tertiary-container)_72%,white_28%))] shadow-[0_10px_22px_color-mix(in_oklch,var(--color-primary)_14%,transparent)]">
<span className="size-4 rounded-full border-2 border-[color-mix(in_oklch,var(--color-primary)_58%,white_42%)]" />
</span>
</div>
<div className="grid gap-2 rounded-[1.25rem] border border-[color-mix(in_oklch,var(--color-border)_68%,transparent)] bg-[color-mix(in_oklch,var(--color-surface-bright)_76%,white_24%)] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.52)]">
<div className="flex items-center gap-2">
<span className="size-2.5 rounded-full bg-[color-mix(in_oklch,var(--color-success)_72%,white_28%)]" />
<span className="h-2.5 w-18 rounded-full bg-[var(--color-border-strong)]" />
</div>
<div className="flex items-center gap-2">
<span className="size-2.5 rounded-full bg-[color-mix(in_oklch,var(--color-primary)_56%,white_44%)]" />
<span className="h-2.5 w-24 rounded-full bg-[var(--color-border)]" />
</div>
<div className="flex items-center gap-2">
<span className="size-2.5 rounded-full bg-[color-mix(in_oklch,var(--color-tertiary)_58%,white_42%)]" />
<span className="h-2.5 w-16 rounded-full bg-[var(--color-border)]" />
</div>
</div>
</div>
);
}
function ReleaseEmptyState({
actionsLayout = "inline",
align = "center",
className,
description = "Adjust the current filters or create a new release to start routing work.",
eyebrow = "No results",
glyph,
layout = "default",
mediaSize = "default",
tone = "default",
@@ -37,8 +80,10 @@ function ReleaseEmptyState({
}: {
actionsLayout?: "inline" | "stack";
align?: "center" | "start";
className?: string;
description?: string;
eyebrow?: string;
glyph?: ReactNode;
layout?: "compact" | "default" | "split";
mediaSize?: "compact" | "default" | "hero";
title?: string;
@@ -47,12 +92,14 @@ function ReleaseEmptyState({
return (
<EmptyState
align={align}
className="w-[min(100%,42rem)]"
className={[layout === "split" ? "w-full max-w-none" : "w-[min(100%,42rem)]", className]
.filter(Boolean)
.join(" ")}
layout={layout}
tone={tone}
>
<EmptyStateMedia size={mediaSize}>
<EmptyStateGlyph />
{glyph ?? <EmptyStateGlyph />}
</EmptyStateMedia>
<EmptyStateHeader align={align}>
<EmptyStateEyebrow>{eyebrow}</EmptyStateEyebrow>
@@ -140,8 +187,10 @@ export const SplitWorkspace: Story = {
<div className="w-[940px] rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-background)] p-6">
<ReleaseEmptyState
align="start"
className="shadow-[0_26px_60px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]"
description="Invite operators, connect the first runtime, and define the initial routing lane to turn this empty workspace into an active control surface."
eyebrow="Workspace setup"
glyph={<WorkspaceSetupGlyph />}
layout="split"
mediaSize="hero"
title="This command center needs its first operator"
@@ -151,6 +200,42 @@ export const SplitWorkspace: Story = {
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"EmptyState keeps motion local to the decorative media and the call-to-action group. The slab itself stays anchored while the media drifts gently and the actions enter in a short sequence. In static motion mode those effects collapse back to near-instant feedback."
}
}
},
render: () => (
<div className="grid w-[960px] gap-4 lg:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
<ReleaseEmptyState
actionsLayout="stack"
align="start"
description="Keep the empty queue readable without turning it into a hero banner. The media should feel alive, not restless."
eyebrow="Queue calm"
layout="compact"
mediaSize="default"
title="No routed work is waiting right now"
tone="subtle"
/>
<ReleaseEmptyState
align="start"
className="shadow-[0_26px_60px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]"
description="The split workspace uses the larger hero media, but the slab stays planted. Motion belongs in the supporting ornament and the CTA entrance, not in the whole panel."
eyebrow="Workspace setup"
glyph={<WorkspaceSetupGlyph />}
layout="split"
mediaSize="hero"
title="Invite the first operator to activate this command center"
tone="accent"
/>
</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 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
+301
View File
@@ -0,0 +1,301 @@
import { Button, Gauge } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
function GaugePanel({
description,
label,
shape = "dial",
tone = "default",
value,
variant = "default"
}: {
description: string;
label: string;
shape?: "dial" | "semi";
tone?: "default" | "subtle" | "accent";
value: number | null;
variant?: "default" | "success" | "warning" | "destructive";
}) {
return (
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-5 shadow-[var(--shadow-sm)]">
<Gauge
description={description}
label={label}
shape={shape}
tone={tone}
value={value}
variant={variant}
/>
</article>
);
}
const meta = {
title: "Components/Gauge",
component: Gauge,
args: {
description: "Lead routing stays stable enough for this week's forecast handoff.",
label: "Forecast confidence",
shape: "dial",
size: "md",
tickCount: 0,
tone: "default",
value: 72,
variant: "default"
},
argTypes: {
className: {
control: false
},
description: {
control: "text"
},
label: {
control: "text"
},
shape: {
control: "radio",
options: ["dial", "semi"]
},
size: {
control: "radio",
options: ["sm", "md", "lg"]
},
tickCount: {
control: {
type: "range",
min: 0,
max: 24,
step: 1
}
},
tone: {
control: "radio",
options: ["default", "subtle", "accent"]
},
value: {
control: {
type: "range",
min: 0,
max: 100,
step: 1
}
},
valueFormatter: {
control: false
},
variant: {
control: "radio",
options: ["default", "success", "warning", "destructive"]
}
},
parameters: {
docs: {
description: {
component:
"Gauge is the system's radial meter for current measurements inside a known range. Use it for capacity, forecast confidence, health scores, and KPI thresholds where the UI should communicate the current level rather than the progress of an ongoing task. The component now stages its readout with a restrained sweep and count-up on entry, while reduced or static motion snaps directly to the final state."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof Gauge>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<div className="w-[380px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Capacity Watch
</p>
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Use Gauge for the current level of a metric, not for task completion.
</h3>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
This component is a meter. It reads like a dashboard instrument and belongs beside KPI
cards, score panels, and health indicators rather than uploads or async job states.
</p>
</div>
<div className="mt-6 flex justify-center">
<Gauge {...args} />
</div>
</div>
)
};
export const Shapes: Story = {
render: () => (
<div className="grid w-[860px] gap-4 md:grid-cols-3">
<GaugePanel
description="Sales planning is stable enough for next week's spend allocation."
label="Forecast confidence"
value={72}
/>
<GaugePanel
description="Coverage is drifting closer to the review threshold than finance would like."
label="Budget saturation"
shape="semi"
tone="subtle"
value={61}
variant="warning"
/>
<GaugePanel
description="The latest model health score is comfortably within the healthy range."
label="Model health"
tone="accent"
value={84}
variant="success"
/>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[780px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="space-y-5">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Gauge anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Gauge keeps the public structure small: one canvas, one SVG ring system, one value
plate, and optional framing copy below the visual.
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<div className="flex justify-center">
<Gauge
description="Qualified coverage is still within the board's target band."
label="Pipeline health"
shape="semi"
tone="subtle"
value={68}
variant="success"
/>
</div>
</div>
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="canvas"</code> owns the
responsive gauge frame, while <code className="text-[var(--color-foreground)]">data-slot="svg"</code>{" "}
holds the radial drawing primitives.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="track"</code> is the full
meter range, and <code className="text-[var(--color-foreground)]">data-slot="indicator"</code>{" "}
is the active measured arc. If a denser dashboard wants calibration marks, opt into
them with <code className="text-[var(--color-foreground)]">tickCount</code>, which
exposes <code className="text-[var(--color-foreground)]">data-slot="tick"</code>.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="value"</code>,
<code className="ml-1 text-[var(--color-foreground)]">data-slot="label"</code>, and
<code className="ml-1 text-[var(--color-foreground)]">data-slot="description"</code>
keep center readout and supporting copy stable for theming, docs, and tests.
</p>
</div>
</div>
</div>
)
};
function GaugeMotionShowcase() {
const [replayKey, setReplayKey] = useState(0);
return (
<div className="grid w-[760px] gap-5 rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_78%,white_22%),color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%))] p-6 shadow-[var(--shadow-sm)]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="max-w-[34rem] space-y-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Motion review
</p>
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Gauge should arrive like an instrument waking up, not a number teleporting in.
</h3>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
The indicator sweeps in once, the center value counts up with it, and reduced/static
motion still resolves instantly.
</p>
</div>
<Button onClick={() => setReplayKey((value) => value + 1)} size="sm" variant="secondary">
Replay startup
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-[var(--radius-lg)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[color-mix(in_oklch,var(--color-background)_82%,white_18%)] p-5 shadow-[var(--shadow-xs)]">
<Gauge
key={`dial-${replayKey}`}
description="Lead scoring remains steady enough for this week's planning handoff."
label="Forecast confidence"
tone="accent"
value={72}
/>
</div>
<div className="rounded-[var(--radius-lg)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[color-mix(in_oklch,var(--color-background)_82%,white_18%)] p-5 shadow-[var(--shadow-xs)]">
<Gauge
key={`semi-${replayKey}`}
description="Budget headroom is drifting closer to the review threshold."
label="Budget saturation"
shape="semi"
tickCount={7}
tone="subtle"
value={61}
variant="warning"
/>
</div>
</div>
</div>
);
}
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Gauge uses a one-time staged startup: the ring sweeps to the target, ticks wake up with the same progress, and the center value counts into place. The effect is intentionally calm and should still feel correct when motion is reduced."
}
}
},
render: () => <GaugeMotionShowcase />
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Gauge uses the ARIA meter pattern, not progressbar. Use it for a current reading inside a bounded range, provide a concrete label, and keep the center number or supporting copy readable enough that color is never the only signal."
}
}
},
render: () => (
<div className="grid w-[840px] gap-4 lg:grid-cols-[minmax(0,0.94fr)_minmax(0,1.06fr)]">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Review guidance
</h3>
<div className="mt-4 grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Choose Gauge for current measurement. Choose Progress for ongoing completion.</p>
<p>Always give the meter a concrete label such as <code>Forecast confidence</code>.</p>
<p>Keep the numeric readout or description meaningful enough that color is supplemental.</p>
</div>
</article>
<div className="flex items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-6">
<Gauge
description="Lead scoring remains predictable enough for finance planning."
label="Forecast confidence"
value={72}
/>
</div>
</div>
)
};
+238
View File
@@ -0,0 +1,238 @@
import { Col, Row } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function GridTile({
body,
eyebrow,
title
}: {
body: string;
eyebrow: string;
title: string;
}) {
return (
<div className="flex min-h-28 flex-col justify-between rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-low)_80%,white_20%),color-mix(in_oklch,var(--color-surface-container)_84%,white_16%))] p-4 shadow-[var(--shadow-xs)]">
<div className="space-y-1.5">
<p className="text-[0.72rem] font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
{eyebrow}
</p>
<p className="text-base font-semibold text-[var(--color-foreground)]">{title}</p>
</div>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">{body}</p>
</div>
);
}
const meta = {
title: "Components/Grid",
component: Row,
args: {
align: "stretch",
gap: "md"
},
argTypes: {
align: {
control: "select",
options: ["start", "center", "end", "stretch"]
},
className: {
control: false
},
gap: {
control: "select",
options: ["none", "xs", "sm", "md", "lg", "xl"]
},
xGap: {
control: "select",
options: ["none", "xs", "sm", "md", "lg", "xl"]
},
yGap: {
control: "select",
options: ["none", "xs", "sm", "md", "lg", "xl"]
}
},
parameters: {
docs: {
description: {
component:
"Use `Row` and `Col` when a screen needs a reusable 12-column layout contract instead of one-off `grid-cols-*` strings or manual width arithmetic. The primitives stay source-owned, tokenized for spacing, and keep responsive placement on the item itself through `span`, `offset`, and breakpoint props such as `md` or `xl`."
}
},
layout: "padded"
},
tags: ["autodocs"]
} satisfies Meta<typeof Row>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<div className="w-full max-w-6xl">
<Row {...args}>
<Col span={12} md={7}>
<GridTile
body="Mission status, copy, and actions can stay in the wider column without any hardcoded pixel math."
eyebrow="Span 12 / md 7"
title="Launch Overview"
/>
</Col>
<Col span={12} md={5}>
<GridTile
body="The companion panel narrows responsively by changing only its column span."
eyebrow="Span 12 / md 5"
title="Launch Guardrails"
/>
</Col>
<Col span={12} md={4}>
<GridTile
body="Smaller summary surfaces can align beneath the main content without custom grid templates."
eyebrow="Span 12 / md 4"
title="Traffic"
/>
</Col>
<Col span={12} md={4}>
<GridTile
body="Spacing stays on the row via semantic gap tokens rather than ad hoc margins."
eyebrow="Span 12 / md 4"
title="Conversion"
/>
</Col>
<Col span={12} md={4}>
<GridTile
body="Each card keeps its own breakpoint behavior through the matching `Col` props."
eyebrow="Span 12 / md 4"
title="Retention"
/>
</Col>
</Row>
</div>
)
};
export const Responsive: Story = {
render: () => (
<div className="w-full max-w-6xl space-y-4">
<p className="max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]">
<code>Col</code> accepts either a direct span like <code>md=&#123;6&#125;</code> or
an object like <code>xl=&#123;&#123; span: 4, offset: 2 &#125;&#125;</code>. Base
layout stays mobile-first, so panels stack by default and only split when the
breakpoint prop applies.
</p>
<Row gap="lg">
<Col span={12} md={6} xl={4}>
<GridTile
body="Stacks full-width on mobile, becomes half width on medium screens, and three-up on extra wide layouts."
eyebrow="12 / md 6 / xl 4"
title="Overview"
/>
</Col>
<Col span={12} md={6} xl={4}>
<GridTile
body="Breakpoints live on the column rather than hidden in a one-off parent template string."
eyebrow="12 / md 6 / xl 4"
title="Active Cohorts"
/>
</Col>
<Col span={12} xl={{ span: 4, offset: 4 }}>
<GridTile
body="Offset creates deliberate asymmetry while keeping the DOM order unchanged."
eyebrow="12 / xl 4 + offset 4"
title="Approval Queue"
/>
</Col>
</Row>
</div>
),
parameters: {
docs: {
description: {
story:
"Use responsive `span` and `offset` on `Col` for the common split-layout cases. That keeps layout intent close to the item being placed instead of scattering width calculations through the parent container."
}
}
}
};
export const Anatomy: Story = {
render: () => (
<div className="w-full max-w-5xl space-y-4">
<Row gap="md">
<Col span={12}>
<div className="rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border-strong)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_74%,white_26%)] p-5">
<p className="text-sm font-medium text-[var(--color-foreground)]">
`Row` renders `data-slot="root"` and owns the shared gap and alignment contract.
</p>
</div>
</Col>
<Col span={12} md={6}>
<div className="rounded-[var(--radius-lg)] border border-dashed border-[var(--color-primary)]/35 bg-[color-mix(in_oklch,var(--color-primary-container)_42%,white_58%)] p-5">
<p className="text-sm font-medium text-[var(--color-foreground)]">
`Col` renders `data-slot="item"` and owns placement through `span`, `offset`,
and breakpoint props.
</p>
</div>
</Col>
<Col span={12} md={6}>
<div className="rounded-[var(--radius-lg)] border border-dashed border-[var(--color-tertiary)]/35 bg-[color-mix(in_oklch,var(--color-tertiary-container)_56%,white_44%)] p-5">
<p className="text-sm font-medium text-[var(--color-foreground)]">
Consumers can still layer normal surface components inside each grid item.
</p>
</div>
</Col>
</Row>
</div>
),
parameters: {
docs: {
description: {
story:
"The contract is intentionally small: one row container with stable layout hooks, and one item primitive that carries its own placement decisions."
}
}
}
};
export const Accessibility: Story = {
render: () => (
<div className="w-full max-w-5xl space-y-4">
<p className="max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]">
Keep the source order equal to the reading order. `offset` should create spatial rhythm,
not fake a different semantic sequence. If the content reads as Step 1, Step 2, Step 3,
keep that order in the DOM and only let the grid change where the panels sit.
</p>
<Row gap="md">
<Col span={12} md={7}>
<GridTile
body="Users encounter this content first both visually and in the DOM."
eyebrow="Step 1"
title="Scan the shared dashboard summary"
/>
</Col>
<Col span={12} md={5}>
<GridTile
body="This still comes second semantically even when the widths differ."
eyebrow="Step 2"
title="Review the flagged metrics"
/>
</Col>
<Col span={12} md={{ span: 5, offset: 7 }}>
<GridTile
body="The third panel uses offset for placement, but not for reordering."
eyebrow="Step 3"
title="Approve the release gate"
/>
</Col>
</Row>
</div>
),
parameters: {
docs: {
description: {
story:
"Cadence UI intentionally omits visual reordering props here. Responsive layout should not create a second, less accessible reading order."
}
}
}
};
@@ -0,0 +1,176 @@
import {
Field,
FieldControl,
FieldDescription,
FieldError,
Input,
InputGroup,
InputGroupPrefix,
InputGroupSuffix,
Label
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function SearchIcon() {
return (
<svg aria-hidden="true" className="size-4" viewBox="0 0 16 16">
<path
d="M7.25 12.5a5.25 5.25 0 1 1 0-10.5a5.25 5.25 0 0 1 0 10.5Zm3.75-1.5 3 3"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
);
}
const meta = {
title: "Components/InputGroup",
component: InputGroup,
args: {
size: "md"
},
argTypes: {
className: {
control: false
},
disabled: {
control: "boolean"
},
invalid: {
control: "boolean"
},
readOnly: {
control: "boolean"
},
required: {
control: "boolean"
},
size: {
control: "select",
options: ["sm", "md", "lg"]
}
},
parameters: {
docs: {
description: {
component:
"Use InputGroup when a text field needs leading or trailing inline content such as a search icon, shortcut hint, status chip, or clear action. Keep plain Input for unadorned fields and let Field continue owning labels, descriptions, and errors."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof InputGroup>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<InputGroup {...args} className="w-[360px]">
<InputGroupPrefix aria-hidden="true">
<SearchIcon />
</InputGroupPrefix>
<Input placeholder="Search launches, owners, or notes" />
<InputGroupSuffix>
<kbd className="rounded-full border border-[var(--color-border)] px-2 py-0.5 text-[0.7rem] font-medium uppercase tracking-[var(--tracking-caps)]">
/
</kbd>
</InputGroupSuffix>
</InputGroup>
)
};
export const States: Story = {
render: () => (
<div className="grid w-[760px] gap-4 sm:grid-cols-2">
<InputGroup>
<InputGroupPrefix aria-hidden="true">
<SearchIcon />
</InputGroupPrefix>
<Input defaultValue="Release workspace" />
<InputGroupSuffix>
<span className="text-xs uppercase tracking-[var(--tracking-caps)]">Live</span>
</InputGroupSuffix>
</InputGroup>
<InputGroup disabled>
<InputGroupPrefix aria-hidden="true">
<SearchIcon />
</InputGroupPrefix>
<Input defaultValue="Disabled search" />
</InputGroup>
<InputGroup invalid>
<InputGroupPrefix aria-hidden="true">
<span>@</span>
</InputGroupPrefix>
<Input defaultValue="ops" />
<InputGroupSuffix>
<span className="text-xs font-medium text-[var(--color-destructive)]">needs domain</span>
</InputGroupSuffix>
</InputGroup>
<InputGroup readOnly>
<InputGroupPrefix aria-hidden="true">
<span>#</span>
</InputGroupPrefix>
<Input defaultValue="release-quiet-cycle" />
<InputGroupSuffix>
<span className="text-xs uppercase tracking-[var(--tracking-caps)]">locked</span>
</InputGroupSuffix>
</InputGroup>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<InputGroup className="w-[420px]">
<InputGroupPrefix>prefix</InputGroupPrefix>
<Input aria-label="InputGroup anatomy" placeholder='data-slot="input"' />
<InputGroupSuffix>suffix</InputGroupSuffix>
</InputGroup>
),
parameters: {
docs: {
description: {
story:
"InputGroup exposes one control container plus explicit prefix, input, and suffix slots so consumers can style or test the affix anatomy without relying on absolute positioning."
}
}
}
};
export const Accessibility: Story = {
render: () => (
<Field invalid className="w-[420px]">
<Label requiredIndicator>Routing search</Label>
<FieldControl>
<InputGroup required>
<InputGroupPrefix aria-hidden="true">
<SearchIcon />
</InputGroupPrefix>
<Input defaultValue="ops" />
<InputGroupSuffix>
<span className="text-xs uppercase tracking-[var(--tracking-caps)]">scope</span>
</InputGroupSuffix>
</InputGroup>
<FieldDescription>Search lanes, owners, or note fragments before assigning work.</FieldDescription>
<FieldError>Add a narrower query before applying bulk actions.</FieldError>
</FieldControl>
</Field>
),
parameters: {
docs: {
description: {
story:
"Decorative affixes should usually be marked `aria-hidden`, while the actual field labelling and error messaging still come from Field plus the inner Input."
}
}
}
};
@@ -0,0 +1,236 @@
import {
Button,
MetricCard,
MetricCardActions,
MetricCardAside,
MetricCardDelta,
MetricCardDescription,
MetricCardEyebrow,
MetricCardFooter,
MetricCardHeader,
MetricCardLabel,
MetricCardLeading,
MetricCardMedia,
MetricCardMetric,
MetricCardValue,
Progress,
Sparkbar,
StatCard,
StatCardDelta,
StatCardDescription,
StatCardHeader,
StatCardLabel,
StatCardMetric,
StatCardValue
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
const reductionBars = [
52, 55, 58, 60, 64, 66, 68, 72, 74, 72, 70, 71, 68, 66, 64, 62, 60, 58, 56, 54
] as const;
function ReductionBars() {
return <Sparkbar height={74} highlightRange={[0, 12]} values={reductionBars} />;
}
function RevenueMetricCard({
layout = "default",
tone = "default"
}: {
layout?: "default" | "split";
tone?: "default" | "subtle" | "accent" | "inverse" | "hero";
}) {
const mediaTone = tone === "hero" ? "hero" : tone === "inverse" ? "inverse" : tone;
const footerTextClassName =
tone === "hero" || tone === "inverse" ? "text-white/68" : "text-[var(--color-muted-foreground)]";
return (
<MetricCard className="w-full max-w-[760px]" interactive layout={layout} tone={tone}>
<MetricCardHeader>
<MetricCardLeading>
<MetricCardEyebrow>Margin watch</MetricCardEyebrow>
<MetricCardLabel>Operational cost reduction</MetricCardLabel>
</MetricCardLeading>
<MetricCardAside>
<MetricCardDelta tone="primary">42%</MetricCardDelta>
</MetricCardAside>
</MetricCardHeader>
<MetricCardMetric>
<MetricCardValue>$38,250</MetricCardValue>
<MetricCardDelta tone="success">+78</MetricCardDelta>
</MetricCardMetric>
<MetricCardDescription>
Assisted routing and tighter triage are keeping servicing overhead inside the target
window.
</MetricCardDescription>
<MetricCardMedia tone={mediaTone}>
<ReductionBars />
</MetricCardMedia>
<MetricCardFooter>
<div className={`flex items-center justify-between gap-4 text-sm ${footerTextClassName}`}>
<span>Quarter target</span>
<span>$101,820</span>
</div>
<Progress tone="subtle" value={42} variant="success" />
<p className={`text-xs leading-5 ${footerTextClassName}`}>
Routing efficiency has stayed inside the guided operating band for six consecutive
cycles.
</p>
</MetricCardFooter>
<MetricCardActions>
<Button variant="secondary">Review forecast</Button>
<Button variant="ghost">Open board</Button>
</MetricCardActions>
</MetricCard>
);
}
const meta = {
title: "Components/MetricCard",
component: RevenueMetricCard,
parameters: {
docs: {
description: {
component:
"MetricCard extends the same KPI language as StatCard but adds structured regions for media, footer detail, and next-step actions. Use it for analytics panels that still need a clear headline metric instead of turning into unstructured custom cards."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof RevenueMetricCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Hover the card directly in the canvas. MetricCard should feel richer than StatCard, but the motion still needs hierarchy: the root slab lifts first, the media surface follows with a softer delayed response, and the actions tray trails last."
}
}
},
render: () => (
<div className="grid w-[1040px] gap-4 rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_78%,white_22%),color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%))] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Motion review
</p>
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
The richer KPI panel should stage its hover response.
</h3>
<p className="max-w-[44rem] text-sm leading-6 text-[var(--color-muted-foreground)]">
MetricCard carries more structure, so the hover response can have a little more depth.
The media region and actions tray should follow the card lift, not compete with it.
</p>
</div>
<div className="grid gap-4">
<RevenueMetricCard layout="split" />
<RevenueMetricCard tone="hero" />
</div>
</div>
)
};
export const Layouts: Story = {
render: () => (
<div className="grid w-[1040px] gap-4">
<RevenueMetricCard layout="default" />
<RevenueMetricCard layout="split" tone="accent" />
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[820px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-5">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Metric card anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
MetricCard reuses the same KPI slots as StatCard and adds dedicated regions for media,
footer detail, and action groups. That keeps dashboard panels consistent even when they
need richer structure.
</p>
</div>
<RevenueMetricCard layout="split" />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="eyebrow"</code>,
<code className="ml-1 text-[var(--color-foreground)]">data-slot="label"</code>,
<code className="ml-1 text-[var(--color-foreground)]">data-slot="value"</code>, and
<code className="ml-1 text-[var(--color-foreground)]">data-slot="delta"</code> stay
aligned with StatCard so analytics panels and compact KPI cards share one core contract.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="leading"</code> and
<code className="ml-1 text-[var(--color-foreground)]">data-slot="aside"</code> keep
header layouts structured, so icons, badges, or next-state chips stop leaking into
story-local flex wrappers.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="media"</code> owns the
chart or visual evidence, while <code className="text-[var(--color-foreground)]">data-slot="footer"</code>{" "}
and <code className="text-[var(--color-foreground)]">data-slot="actions"</code> cover
the follow-up details that should not be improvised ad hoc.
</p>
</div>
</div>
</div>
)
};
export const Composition: Story = {
parameters: {
docs: {
description: {
story:
"Reach for StatCard when the panel is just one KPI and a sentence. Move to MetricCard when the same KPI needs supporting evidence, target progress, or explicit next actions."
}
}
},
render: () => (
<div className="grid w-[1040px] gap-4 lg:grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]">
<StatCard className="w-full">
<StatCardHeader>
<StatCardLabel>Forecast confidence</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>31%</StatCardValue>
<StatCardDelta tone="primary">+9.2%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
The signal is healthy enough for planning, but still worth watching week to week.
</StatCardDescription>
</StatCard>
<RevenueMetricCard layout="split" />
</div>
)
};
export const EmphasisTones: Story = {
parameters: {
docs: {
description: {
story:
"Use `inverse` for dark tonal KPI panels and `hero` when the card needs to carry the visual emphasis for a dashboard row without falling back to a raw `Card`."
}
}
},
render: () => (
<div className="grid w-[1040px] gap-4 lg:grid-cols-2">
<RevenueMetricCard tone="inverse" />
<RevenueMetricCard tone="hero" />
</div>
)
};
+244 -17
View File
@@ -1,10 +1,60 @@
import { Progress } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function ProgressCard({
accent,
label,
note,
pattern = "linear",
segmentCount,
tone = "default",
value,
variant = "default"
}: {
accent: string;
label: string;
note: string;
pattern?: "linear" | "segmented";
segmentCount?: number;
tone?: "default" | "subtle";
value: number | null;
variant?: "default" | "success" | "warning" | "destructive";
}) {
const displayValue = value == null ? "Syncing" : `${value}%`;
return (
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-5 shadow-[var(--shadow-sm)]">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<p
className="text-xs uppercase tracking-[var(--tracking-caps)]"
style={{ color: accent }}
>
{label}
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">{note}</p>
</div>
<span className="text-sm font-medium text-[var(--color-foreground)]">{displayValue}</span>
</div>
<Progress
aria-label={label}
className="mt-4"
pattern={pattern}
segmentCount={segmentCount}
tone={tone}
value={value}
variant={variant}
/>
</article>
);
}
const meta = {
title: "Components/Progress",
component: Progress,
args: {
pattern: "linear",
segmentCount: 24,
size: "md",
value: 64,
variant: "default"
@@ -17,6 +67,18 @@ const meta = {
control: "radio",
options: ["sm", "md", "lg"]
},
pattern: {
control: "radio",
options: ["linear", "segmented"]
},
segmentCount: {
control: {
type: "range",
min: 6,
max: 36,
step: 1
}
},
tone: {
control: "radio",
options: ["default", "subtle"]
@@ -35,6 +97,12 @@ const meta = {
}
},
parameters: {
docs: {
description: {
component:
"Progress is the system's inline status channel for work that is actively advancing inside the current view. Use the default linear pattern for classic completion bars, or switch to the segmented pattern when a dashboard metric needs a more discrete meter like rollout targets, cost reduction goals, and operational scorecards."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -45,35 +113,194 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => <Progress aria-label="Upload progress" className="w-[320px]" {...args} />
render: (args) => (
<div className="w-[380px] 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="flex items-start justify-between gap-4">
<div className="space-y-1">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Asset Upload
</p>
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)]">
Publishing release visuals
</h3>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Use the same component for classic uploads and segmented dashboard meters by swapping
only the pattern instead of reaching for a separate one-off implementation.
</p>
</div>
<span className="rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--color-primary-container)_70%,white_30%)] px-3 py-1 text-sm font-medium text-[var(--color-on-primary-container)]">
{args.value}%
</span>
</div>
<Progress aria-label="Upload progress" className="mt-5" {...args} />
</div>
)
};
export const Variants: Story = {
export const Patterns: Story = {
render: () => (
<div className="grid w-[360px] gap-4">
<Progress aria-label="Primary progress" value={42} variant="default" />
<Progress aria-label="Success progress" value={74} variant="success" />
<Progress aria-label="Warning progress" value={58} variant="warning" />
<Progress aria-label="Destructive progress" value={86} variant="destructive" />
<div className="grid w-[720px] gap-4 md:grid-cols-2">
<ProgressCard
accent="var(--color-primary)"
label="Release Assets"
note="The marketing bundle is halfway through the upload queue."
value={46}
/>
<ProgressCard
accent="var(--color-foreground)"
label="Operational Cost Reduction"
note="Segmented meters work better for dashboard targets where discrete progress reads faster than a single slab."
pattern="segmented"
segmentCount={24}
tone="subtle"
value={42}
/>
<ProgressCard
accent="color-mix(in oklch, var(--color-success) 78%, var(--color-foreground))"
label="Audience Sync"
note="Subscriber segments are reconciling against the latest CRM export."
tone="subtle"
value={74}
variant="success"
/>
<ProgressCard
accent="color-mix(in oklch, var(--color-warning) 72%, var(--color-foreground))"
label="Compliance Review"
note="A few rule checks are still pending before the rollout can advance."
value={58}
variant="warning"
/>
<ProgressCard
accent="var(--color-destructive)"
label="Recovery Job"
note="The pipeline is retrying a failed package publish after signature drift."
tone="subtle"
value={91}
variant="destructive"
/>
</div>
)
};
export const States: Story = {
render: () => (
<div className="grid w-[360px] gap-4">
<div className="space-y-2">
<p className="text-sm font-medium">Determinate</p>
<Progress aria-label="Determinate progress" value={68} />
<div className="grid w-[720px] gap-4 md:grid-cols-3">
<ProgressCard
accent="var(--color-primary)"
label="Determinate"
note="Linear progress remains the best fit for straightforward task completion."
value={68}
/>
<ProgressCard
accent="var(--color-foreground)"
label="Segmented Goal"
note="The segmented pattern makes discrete target progress easier to scan in dense dashboards."
pattern="segmented"
segmentCount={20}
tone="subtle"
value={42}
/>
<ProgressCard
accent="color-mix(in oklch, var(--color-success) 78%, var(--color-foreground))"
label="Complete"
note="Completion stays quiet and stable instead of introducing a second celebratory treatment."
value={100}
variant="success"
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Indeterminate</p>
<Progress aria-label="Indeterminate progress" value={null} variant="success" />
)
};
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-4">
<div className="space-y-1">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Progress anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
The public styling contract stays intentionally small: a root track and one indicator
layer, with the data attributes carrying the meaningful state.
</p>
</div>
<div className="rounded-[var(--radius-md)] border border-dashed border-[var(--color-border-strong)] bg-[var(--color-background)] p-5">
<div className="grid gap-4">
<Progress aria-label="Progress anatomy example" size="lg" value={68} />
<Progress
aria-label="Progress anatomy segmented example"
pattern="segmented"
segmentCount={20}
size="lg"
tone="subtle"
value={42}
/>
</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="root"</code> is the tonal
track. It exposes <code className="text-[var(--color-foreground)]">data-size</code>,{" "}
<code className="text-[var(--color-foreground)]">data-pattern</code>,{" "}
<code className="text-[var(--color-foreground)]">data-tone</code>,{" "}
<code className="text-[var(--color-foreground)]">data-variant</code>, and{" "}
<code className="text-[var(--color-foreground)]">data-state</code> so consumers can
respond to contract-level state instead of incidental class names.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="indicator"</code> is the
filled value layer for the linear pattern. It mirrors pattern, size, variant, and
state so tests and downstream themes can target the active bar without reaching into
private markup.
</p>
<p>
In segmented mode, <code className="text-[var(--color-foreground)]">data-slot="segments"</code>{" "}
wraps the repeated meter cells and each{" "}
<code className="text-[var(--color-foreground)]">data-slot="segment"</code> exposes
active state for dashboard-style styling and assertions.
</p>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Complete</p>
<Progress aria-label="Complete progress" value={100} variant="success" />
</div>
</div>
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Determinate values should feel calm and weighted, with the filled slab scaling into place instead of animating layout width. Indeterminate progress keeps a restrained sweep in interactive mode, then falls back to a static reading when reduced or static motion is active."
}
}
},
render: () => (
<div className="grid w-[720px] gap-4 md:grid-cols-3">
<ProgressCard
accent="var(--color-primary)"
label="Determinate"
note="A known value keeps the indicator anchored and scales the filled slab instead of animating layout width."
value={68}
/>
<ProgressCard
accent="var(--color-foreground)"
label="Segmented Meter"
note="Discrete bars stay crisp at a glance and match dashboard KPI treatments."
pattern="segmented"
segmentCount={24}
tone="subtle"
value={42}
/>
<ProgressCard
accent="color-mix(in oklch, var(--color-success) 78%, var(--color-foreground))"
label="Segmented Indeterminate"
note="Unknown duration shifts the segmented pattern into a staggered pulse instead of pretending there is a precise width."
pattern="segmented"
segmentCount={20}
tone="subtle"
value={null}
variant="success"
/>
</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>
)
};
+71 -2
View File
@@ -1,4 +1,18 @@
import { Field, FieldControl, FieldDescription, FieldError, Label, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@ai-ui/ui";
import {
Field,
FieldControl,
FieldDescription,
FieldError,
Label,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
async function waitForCondition(
@@ -23,6 +37,12 @@ const meta = {
title: "Components/Select",
component: Select,
parameters: {
docs: {
description: {
component:
"Select is the compact choice surface for a fixed option set. It should feel lighter than Combobox, but it should still share the same motion language: a restrained rise on open, adhesive highlighted rows, and a selected state that reads as a settled tonal slab instead of a hard admin-menu snap."
}
},
layout: "centered"
},
tags: ["autodocs"]
@@ -34,7 +54,7 @@ type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: () => (
<div className="w-[320px]">
<div className="w-[360px]">
<Select defaultValue="editorial">
<SelectTrigger aria-label="Review lane">
<SelectValue placeholder="Choose a review lane" />
@@ -44,7 +64,10 @@ export const Playground: Story = {
<SelectLabel>Review lane</SelectLabel>
<SelectItem value="editorial">Editorial review</SelectItem>
<SelectItem value="design">Design review</SelectItem>
<SelectSeparator />
<SelectLabel>Specialist lane</SelectLabel>
<SelectItem value="legal">Legal review</SelectItem>
<SelectItem value="ops">Operations review</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
@@ -111,3 +134,49 @@ export const WithField: Story = {
</Field>
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Open the list and move through the options with the pointer or keyboard. The menu should fade and rise as one surface, highlighted rows should glide lightly instead of snapping, and the checked row should feel a touch more settled than the transient highlight."
}
}
},
render: () => (
<div className="grid w-[420px] 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 Select lighter than Combobox, but not mechanically flat.
</h3>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
This surface is still a simple fixed-choice select. The motion should clarify focus and
settled selection without turning the menu into an animated showcase.
</p>
</div>
<Select defaultValue="design">
<SelectTrigger aria-label="Motion review lane">
<SelectValue placeholder="Choose a review lane" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Core lanes</SelectLabel>
<SelectItem value="editorial">Editorial review</SelectItem>
<SelectItem value="design">Design review</SelectItem>
<SelectItem value="engineering">Engineering review</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Specialist lanes</SelectLabel>
<SelectItem value="legal">Legal review</SelectItem>
<SelectItem value="ops">Operations review</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</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>
)
};
+13 -1
View File
@@ -51,7 +51,7 @@ const meta = {
docs: {
description: {
component:
"Tabs is the system's in-place content switcher for peer views that belong to the same context. It exposes stable slots for the root, list, triggers, and panels, supports orientation changes, and keeps active-state styling on the trigger rather than moving users into a new page."
"Tabs is the system's in-place content switcher for peer views that belong to the same context. It exposes stable slots for the root, list, triggers, and panels, supports orientation changes, and now uses a shared active pill so state changes feel like one continuous surface sliding between peer views instead of isolated button toggles."
}
},
layout: "centered"
@@ -104,6 +104,18 @@ export const Anatomy: Story = {
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"The active state now uses a shared pill that glides between triggers with restrained timing. It should feel like one calm slab moving across the control, not like each tab is popping independently."
}
}
},
render: () => <ReleaseTabs includeDisabled />
};
export const Accessibility: Story = {
parameters: {
docs: {
+1 -1
View File
@@ -57,7 +57,7 @@ const meta = {
docs: {
description: {
component:
"Tooltip is the system's smallest assistive overlay. Use it for terse clarification, shortcut hints, or icon-only controls that benefit from a compact label on hover and focus. If the content needs interaction or more than a sentence, switch to popover instead."
"Tooltip is the system's smallest assistive overlay. Use it for terse clarification, shortcut hints, or icon-only controls that benefit from a compact label on hover and focus. It uses a short rise-and-fade so the label still feels spatial without carrying the heavier panel motion reserved for popovers and dialogs. If the content needs interaction or more than a sentence, switch to popover instead."
}
},
layout: "centered"
@@ -0,0 +1,172 @@
import {
Button,
Field,
FieldControl,
FieldDescription,
FieldError,
Label,
ValueField,
ValueFieldPrefix,
ValueFieldSuffix,
ValueFieldValue
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function CopyGlyph() {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
<rect height="8" rx="1.5" stroke="currentColor" strokeWidth="1.4" width="6.5" x="6.25" y="3.75" />
<path
d="M4.25 10.25H3.5A1.75 1.75 0 0 1 1.75 8.5v-5A1.75 1.75 0 0 1 3.5 1.75h5A1.75 1.75 0 0 1 10.25 3.5v.75"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.4"
/>
</svg>
);
}
const meta = {
title: "Components/ValueField",
component: ValueField,
args: {
size: "md"
},
argTypes: {
className: {
control: false
},
disabled: {
control: "boolean"
},
invalid: {
control: "boolean"
},
readOnly: {
control: "boolean"
},
required: {
control: "boolean"
},
size: {
control: "select",
options: ["sm", "md", "lg"]
}
},
parameters: {
docs: {
description: {
component:
"Use ValueField when product needs to show a single-line read-only value that still belongs to form-like field anatomy, such as backup codes, generated ids, or immutable environment labels. Prefer it over `Input readOnly` when the user is not editing the value and the surface should read as display-first instead of text-entry."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof ValueField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {
render: (args) => (
<Field className="w-[420px]">
<Label>Manual backup code</Label>
<FieldControl>
<ValueField {...args}>
<ValueFieldValue className="font-mono font-semibold uppercase tracking-[0.18em]">
ORBT-7X92-KLL9-001P
</ValueFieldValue>
<ValueFieldSuffix>
<Button aria-label="Copy backup code" size="icon" type="button" variant="ghost">
<CopyGlyph />
</Button>
</ValueFieldSuffix>
</ValueField>
<FieldDescription>Use this code if you cannot scan the QR graphic.</FieldDescription>
</FieldControl>
</Field>
)
};
export const States: Story = {
render: () => (
<div className="grid w-[780px] gap-4 sm:grid-cols-2">
<ValueField>
<ValueFieldValue>prod-eu-central-1</ValueFieldValue>
</ValueField>
<ValueField disabled>
<ValueFieldValue>launch-window-locked</ValueFieldValue>
</ValueField>
<ValueField invalid>
<ValueFieldPrefix aria-hidden="true">#</ValueFieldPrefix>
<ValueFieldValue className="font-mono">release</ValueFieldValue>
<ValueFieldSuffix>
<span className="text-xs font-medium text-[var(--color-destructive)]">duplicate</span>
</ValueFieldSuffix>
</ValueField>
<ValueField size="lg">
<ValueFieldValue className="font-mono uppercase tracking-[0.18em]">
ORBT-7X92-KLL9-001P
</ValueFieldValue>
<ValueFieldSuffix>
<Button aria-label="Copy code" size="icon" type="button" variant="ghost">
<CopyGlyph />
</Button>
</ValueFieldSuffix>
</ValueField>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<ValueField className="w-[420px]">
<ValueFieldPrefix>prefix</ValueFieldPrefix>
<ValueFieldValue>data-slot="value"</ValueFieldValue>
<ValueFieldSuffix>suffix</ValueFieldSuffix>
</ValueField>
),
parameters: {
docs: {
description: {
story:
"ValueField keeps the display surface on the root, exposes the actual displayed string through `data-slot=\"value\"`, and uses optional prefix/suffix slots for supporting inline affordances such as icons or copy actions."
}
}
}
};
export const Accessibility: Story = {
render: () => (
<Field invalid className="w-[440px]">
<Label>Deployment target</Label>
<FieldControl>
<ValueField>
<ValueFieldValue>prod-eu-central-1</ValueFieldValue>
<ValueFieldSuffix>
<Button aria-label="Copy deployment target" size="icon" type="button" variant="ghost">
<CopyGlyph />
</Button>
</ValueFieldSuffix>
</ValueField>
<FieldDescription>
Keep the value readable and label-connected even when copy actions are present.
</FieldDescription>
<FieldError>This value is stale and needs regeneration.</FieldError>
</FieldControl>
</Field>
),
parameters: {
docs: {
description: {
story:
"Use ValueField inside `Field` when labels, descriptions, and validation messaging still matter, but the displayed value itself is not editable. This keeps the value surface descriptive without pretending to be an input."
}
}
}
};
@@ -0,0 +1,274 @@
import {
AppShell,
AppShellBody,
AppShellFooter,
AppShellHeader,
AppShellMain,
AppShellSidebar,
Badge,
Button,
PageFooter,
PageFooterActions,
PageFooterDescription,
PageFooterLeading,
PageFooterTitle,
PageHeader,
PageHeaderActions,
PageHeaderDescription,
PageHeaderEyebrow,
PageHeaderLeading,
PageHeaderTitle,
SidebarNav,
SidebarNavContent,
SidebarNavHeader,
SidebarNavItem,
SidebarNavItemIcon,
SidebarNavItemLabel,
SidebarNavItems,
SidebarNavSection,
SidebarNavSectionLabel,
StatCard,
StatCardDelta,
StatCardDescription,
StatCardHeader,
StatCardLabel,
StatCardMetric,
StatCardValue
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function ShellMark() {
return (
<span className="flex size-10 items-center justify-center rounded-[1rem] bg-[linear-gradient(145deg,var(--color-foreground),color-mix(in_oklch,var(--color-foreground)_74%,var(--color-primary)_26%))] text-[var(--color-background)]">
<svg aria-hidden="true" className="size-5" fill="none" viewBox="0 0 24 24">
<path
d="M5.25 7.2c0-1.08.87-1.95 1.95-1.95h9.6c1.08 0 1.95.87 1.95 1.95v9.6c0 1.08-.87 1.95-1.95 1.95H7.2a1.95 1.95 0 0 1-1.95-1.95V7.2Z"
fill="currentColor"
opacity="0.24"
/>
<path
d="m8.25 8.85 3.2 3.2-3.2 3.2M11.55 12.05h4.2"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.9"
/>
</svg>
</span>
);
}
function RailIcon({ kind }: { kind: "overview" | "pipeline" | "team" }) {
switch (kind) {
case "pipeline":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<path d="M6 7.5h12M6 12h8m-8 4.5h12" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
</svg>
);
case "team":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<circle cx="9" cy="9" r="2.25" stroke="currentColor" strokeWidth="1.7" />
<circle cx="16" cy="10" r="1.9" stroke="currentColor" strokeWidth="1.7" opacity="0.7" />
<path
d="M5.5 18c.35-2.55 2.22-4 4.5-4s4.15 1.45 4.5 4M14.2 18c.16-1.36.93-2.28 2.25-2.9"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.7"
/>
</svg>
);
case "overview":
default:
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<rect x="4" y="4" width="6" height="6" rx="1.5" fill="currentColor" />
<rect x="14" y="4" width="6" height="10" rx="1.5" fill="currentColor" opacity="0.75" />
<rect x="4" y="14" width="10" height="6" rx="1.5" fill="currentColor" opacity="0.65" />
</svg>
);
}
}
function AppShellExample() {
return (
<div className="w-full max-w-[1320px]">
<AppShell layout="sidebar" surface="panel">
<AppShellSidebar>
<SidebarNav className="h-full">
<SidebarNavHeader>
<div className="flex items-center gap-3">
<ShellMark />
<div>
<p
className="text-[1.35rem] font-semibold tracking-[-0.04em] text-[var(--color-foreground)]"
style={{ fontFamily: "var(--font-display)" }}
>
ScaleOps
</p>
<p className="text-sm text-[var(--color-muted-foreground)]">Workspace shell</p>
</div>
</div>
</SidebarNavHeader>
<SidebarNavContent>
<SidebarNavSection>
<SidebarNavSectionLabel>Main</SidebarNavSectionLabel>
<SidebarNavItems>
<SidebarNavItem active>
<SidebarNavItemIcon>
<RailIcon kind="overview" />
</SidebarNavItemIcon>
<SidebarNavItemLabel>Overview</SidebarNavItemLabel>
</SidebarNavItem>
<SidebarNavItem>
<SidebarNavItemIcon>
<RailIcon kind="pipeline" />
</SidebarNavItemIcon>
<SidebarNavItemLabel>Pipeline</SidebarNavItemLabel>
</SidebarNavItem>
<SidebarNavItem>
<SidebarNavItemIcon>
<RailIcon kind="team" />
</SidebarNavItemIcon>
<SidebarNavItemLabel>Team</SidebarNavItemLabel>
</SidebarNavItem>
</SidebarNavItems>
</SidebarNavSection>
</SidebarNavContent>
</SidebarNav>
</AppShellSidebar>
<AppShellBody>
<AppShellHeader>
<PageHeader density="compact" variant="default">
<PageHeaderLeading>
<PageHeaderEyebrow>Workspace shell</PageHeaderEyebrow>
<PageHeaderTitle>One shared frame for navigation, header, and page body.</PageHeaderTitle>
<PageHeaderDescription>
AppShell owns the outer layout contract so product pages can swap content without
reinventing the sidebar and body structure.
</PageHeaderDescription>
</PageHeaderLeading>
<PageHeaderActions>
<Button>Primary action</Button>
<Button variant="secondary">Secondary action</Button>
</PageHeaderActions>
</PageHeader>
</AppShellHeader>
<AppShellMain>
<div className="grid gap-4 lg:grid-cols-2">
<StatCard>
<StatCardHeader>
<StatCardLabel>Live revenue pulse</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>$101,820</StatCardValue>
<StatCardDelta tone="success">+84</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
AI-assisted follow-up is still the strongest lift across the board window.
</StatCardDescription>
</StatCard>
<StatCard>
<StatCardHeader>
<StatCardLabel>Forecast confidence</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>31%</StatCardValue>
<StatCardDelta tone="primary">+9.2%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
Strong enough for planning, but still worth watching through the anomaly lane.
</StatCardDescription>
</StatCard>
</div>
</AppShellMain>
<AppShellFooter>
<PageFooter tone="subtle">
<PageFooterLeading>
<PageFooterTitle>Shell state is synchronized across the workspace.</PageFooterTitle>
<PageFooterDescription>
The page footer closes the shell with status and low-emphasis follow-up actions.
</PageFooterDescription>
</PageFooterLeading>
<PageFooterActions>
<Badge tone="primary" variant="subtle">
Ready for review
</Badge>
</PageFooterActions>
</PageFooter>
</AppShellFooter>
</AppShellBody>
</AppShell>
</div>
);
}
const meta = {
title: "Patterns/AppShell",
component: AppShellExample,
parameters: {
docs: {
description: {
component:
"AppShell is the shared outer layout pattern for workspace-style screens. Use it when a product view needs a repeatable sidebar/body frame that can host `SidebarNav`, `PageHeader`, `PageFooter`, and arbitrary content panels without each scene redefining the same shell grid. The shell should feel quietly awake through soft surface motion and focus-within depth changes, while leaving stronger animation work to the content living inside it."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof AppShellExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Anatomy: Story = {
render: () => (
<div className="grid w-[1320px] gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
App shell anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
The stable shell regions are the sidebar, body, header, main content lane, and footer.
That keeps full-page scenes aligned around one layout contract instead of local CSS grids.
</p>
</div>
<AppShellExample />
</div>
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"The panel shell should feel a little more alive than a static CSS grid, but it should still read as infrastructure. Hover and focus inside the shell to check the subtle surface wake-up without competing with the contained components."
}
}
},
render: () => (
<div className="grid w-[1320px] gap-5 rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_80%,white_20%),color-mix(in_oklch,var(--color-surface-container-low)_88%,white_12%))] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Motion review
</p>
<p className="max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]">
AppShell now carries a restrained surface glow and depth response so the workspace frame
feels staged, not inert. The motion should stay in the background behind the content.
</p>
</div>
<AppShellExample />
</div>
)
};
@@ -0,0 +1,200 @@
import { ChallengeProgress } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
import type { ComponentProps } from "react";
function ChallengeMark() {
return (
<svg aria-hidden="true" className="size-5" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8.25" stroke="currentColor" strokeOpacity="0.22" strokeWidth="1.8" />
<path
d="M12 4.8a7.2 7.2 0 1 1-6.54 4.2"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2.2"
/>
<path d="M7.6 5.4h4.2v4.2" stroke="currentColor" strokeLinecap="round" strokeWidth="2.2" />
</svg>
);
}
function ChallengeProgressExample({
items = [
{
max: 8_000,
maxLabel: "$8,000",
resultValue: "$8,000",
statusLabel: "Passed",
statusTone: "success",
targetLabel: "Profit target",
targetValue: "$8,000",
value: 8_000,
variant: "success"
},
{
max: 10_000,
maxLabel: "$10,000",
resultValue: "$4,000",
statusLabel: "Phase 2",
statusTone: "primary",
targetLabel: "Profit target",
targetValue: "$10,000",
value: 4_000
}
]
}: {
items?: ComponentProps<typeof ChallengeProgress>["items"];
}) {
return <ChallengeProgress className="w-full max-w-[980px]" icon={<ChallengeMark />} items={items} title="Challenge progress" />;
}
const meta = {
title: "Patterns/ChallengeProgress",
component: ChallengeProgressExample,
parameters: {
docs: {
description: {
component:
"ChallengeProgress is the stacked target panel for challenge-style scoreboards, payout milestones, or multi-phase trading goals. Use it when a view needs several related progress rows with target, result, and compact status chips in one shared slab instead of improvising custom card markup around `Progress`."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof ChallengeProgressExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const States: Story = {
render: () => (
<div className="grid w-[1040px] gap-4">
<ChallengeProgressExample />
<ChallengeProgressExample
items={[
{
max: 12_000,
maxLabel: "$12,000",
progressLabel: "78%",
resultValue: "$9,350",
statusLabel: "Review",
statusTone: "warning",
targetLabel: "Payout target",
targetValue: "$12,000",
value: 9_350,
variant: "warning"
},
{
max: 6_000,
maxLabel: "$6,000",
resultValue: "$0",
statusLabel: "Queued",
statusTone: "neutral",
targetLabel: "Buffer target",
targetValue: "$6,000",
value: null
}
]}
/>
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="grid w-[1040px] gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Challenge progress anatomy
</p>
<p className="max-w-3xl text-sm leading-6 text-[var(--color-muted-foreground)]">
The pattern keeps the outer slab, repeated challenge rows, status cluster, and footer
values stable so dashboard scenes can reuse one contract instead of restyling `Card` and
`Progress` each time.
</p>
</div>
<ChallengeProgressExample />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="header"</code> and{" "}
<code className="text-[var(--color-foreground)]">data-slot="title"</code> own the shared
panel framing.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="list"</code> wraps the
repeated rows, and each <code className="text-[var(--color-foreground)]">data-slot="item"</code>{" "}
exposes <code className="text-[var(--color-foreground)]">data-state</code> and{" "}
<code className="text-[var(--color-foreground)]">data-variant</code> for complete,
active, or indeterminate styling.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="status"</code> groups the
two right-side chips, while <code className="text-[var(--color-foreground)]">data-slot="meter"</code>{" "}
contains the actual segmented <code className="text-[var(--color-foreground)]">Progress</code>{" "}
primitive.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="footer"</code> keeps the
result readout and the right-aligned max label consistent across rows.
</p>
</div>
</div>
)
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Keep every row's progressbar label concrete, keep the textual status chips visible, and do not let tone alone communicate whether a challenge passed, is active, or is still queued."
}
}
},
render: () => (
<div className="grid w-[1040px] gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Review guidance
</h3>
<div className="mt-4 grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Name the progressbar after the thing being measured, not just the phase.</p>
<p>Keep the status text explicit, such as <code>Passed</code>, <code>Review</code>, or <code>Queued</code>.</p>
<p>Use the bottom result row to restate the numeric outcome so the bar is not the only source of truth.</p>
<p>If a row is waiting on external work, a pending chip and indeterminate progress should still make sense without motion.</p>
</div>
</article>
<ChallengeProgressExample
items={[
{
max: 10_000,
maxLabel: "$10,000",
progressAriaLabel: "Phase two profit target progress",
resultValue: "$4,000",
statusLabel: "Phase 2",
statusTone: "primary",
targetLabel: "Profit target",
targetValue: "$10,000",
value: 4_000
},
{
max: 6_000,
maxLabel: "$6,000",
progressAriaLabel: "Buffer target progress",
resultValue: "$0",
statusLabel: "Queued",
statusTone: "neutral",
targetLabel: "Buffer target",
targetValue: "$6,000",
value: null
}
]}
/>
</div>
)
};
@@ -0,0 +1,102 @@
import {
Badge,
Button,
PageFooter,
PageFooterActions,
PageFooterDescription,
PageFooterLeading,
PageFooterMeta,
PageFooterTitle
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function PageFooterExample({
tone = "default"
}: {
tone?: "default" | "subtle" | "accent";
}) {
return (
<div className="w-full max-w-[1100px]">
<PageFooter tone={tone}>
<PageFooterLeading>
<PageFooterMeta>
<Badge tone="primary" variant="subtle">
Board synced
</Badge>
<Badge tone="success" variant="outline">
Forecast live
</Badge>
</PageFooterMeta>
<PageFooterTitle>
Revenue command center is ready for the weekly board review.
</PageFooterTitle>
<PageFooterDescription>
Assisted routing, forecast activity, and anomaly monitoring are all updated from the
same operating window.
</PageFooterDescription>
</PageFooterLeading>
<PageFooterActions>
<Button variant="ghost">Open audit log</Button>
<Button variant="secondary">Share snapshot</Button>
</PageFooterActions>
</PageFooter>
</div>
);
}
const meta = {
title: "Patterns/PageFooter",
component: PageFooterExample,
parameters: {
docs: {
description: {
component:
"PageFooter is the shared low-emphasis closing bar for pages and workspaces that still need status, audit, or follow-up actions after the main content. Use it instead of hand-rolling one more bordered slab at the bottom of every screen. Its motion should stay soft and infrastructural: a tiny slab lift and coordinated badge/action polish, not a second hero panel."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof PageFooterExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Tones: Story = {
render: () => (
<div className="grid w-[1100px] gap-4">
<PageFooterExample tone="default" />
<PageFooterExample tone="subtle" />
<PageFooterExample tone="accent" />
</div>
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Hover the badges and action cluster to review the closing-bar motion. The footer should feel slightly buoyant and coordinated, but it should remain quieter than the primary page content above it."
}
}
},
render: () => (
<div className="grid w-[1100px] gap-5 rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_80%,white_20%),color-mix(in_oklch,var(--color-surface-container-low)_88%,white_12%))] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Motion review
</p>
<p className="max-w-2xl text-sm leading-6 text-[var(--color-muted-foreground)]">
The footer should read like a softly lifted closing slab. Its badges and actions can wake
up on hover, but the whole region should stay calm and secondary.
</p>
</div>
<PageFooterExample tone="subtle" />
</div>
)
};
@@ -0,0 +1,135 @@
import {
Badge,
Button,
PageHeader,
PageHeaderActions,
PageHeaderDescription,
PageHeaderEyebrow,
PageHeaderLeading,
PageHeaderMeta,
PageHeaderTitle
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function PageHeaderExample({
align = "start",
density = "comfortable",
variant = "default"
}: {
align?: "start" | "end";
density?: "comfortable" | "compact";
variant?: "hero" | "default" | "compact";
}) {
return (
<div className="w-full max-w-[1100px]">
<PageHeader align={align} density={density} variant={variant}>
<PageHeaderLeading>
<PageHeaderMeta>
<PageHeaderEyebrow>Friday, Dec 5, 2025</PageHeaderEyebrow>
<Badge tone="primary" variant="subtle">
Calm pulse
</Badge>
</PageHeaderMeta>
<PageHeaderTitle>Welcome back, Nathan.</PageHeaderTitle>
<PageHeaderDescription>
Revenue operations is holding a calm pulse today. Growth is still being led by
AI-assisted follow-up, while cost pressure stays within the board target window.
</PageHeaderDescription>
</PageHeaderLeading>
<PageHeaderActions>
<Button>Morning brief</Button>
<Button variant="secondary">Watch anomalies</Button>
</PageHeaderActions>
</PageHeader>
</div>
);
}
const meta = {
title: "Patterns/PageHeader",
component: PageHeaderExample,
parameters: {
docs: {
description: {
component:
"PageHeader is the shared top-of-page pattern for workspace and dashboard views. Use it when a screen needs one primary title stack with supporting context and a separate action cluster, instead of reassembling the same flex and spacing rules in each scene. Its motion should stay structural and calm: the header itself carries only a light ornamental wake-up, while badges and actions inherit a restrained lift that keeps the top rail feeling alive without turning it into a hero animation."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof PageHeaderExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Variants: Story = {
render: () => (
<div className="mx-auto grid w-full max-w-[1100px] gap-8">
<PageHeaderExample align="end" variant="hero" />
<PageHeaderExample variant="default" />
<PageHeaderExample density="compact" variant="compact" />
</div>
)
};
export const Anatomy: Story = {
render: () => (
<div className="mx-auto grid w-full max-w-[1100px] gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Page header anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Keep the title stack in the leading slot and keep controls in the actions slot. The
pattern stays flexible enough for badges, dates, or status markers through the meta slot
without turning every page header into custom layout glue. Use `variant`, `density`, and
`align` on the root when a page needs hero weight, a quieter default header, or a tighter
compact treatment.
</p>
</div>
<PageHeaderExample />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="leading"</code> owns the
meta, title, and description stack.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="actions"</code> is reserved
for the page-level control cluster so page scenes stop improvising this split every time.
</p>
</div>
</div>
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Hover the badges and action cluster. The pattern itself should feel gently staged through ornament and shared transition polish, but the main title stack should stay anchored and readable."
}
}
},
render: () => (
<div className="mx-auto grid w-full max-w-[1100px] gap-5 rounded-[var(--radius-xl)] border border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface)_78%,white_22%),color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%))] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-primary)]">
Motion review
</p>
<p className="max-w-2xl text-sm leading-6 text-[var(--color-muted-foreground)]">
PageHeader should not animate like a card. It should feel like polished infrastructure:
the slab wakes up through a light glow and the attached controls carry the interaction lift.
</p>
</div>
<PageHeaderExample align="end" variant="hero" />
</div>
)
};
@@ -0,0 +1,236 @@
import {
Badge,
Button,
SidebarNav,
SidebarNavContent,
SidebarNavFooter,
SidebarNavHeader,
SidebarNavItem,
SidebarNavItemBadge,
SidebarNavItemIcon,
SidebarNavItemLabel,
SidebarNavItems,
SidebarNavSection,
SidebarNavSectionLabel
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function PatternMark() {
return (
<span className="flex size-11 items-center justify-center rounded-[1.1rem] bg-[linear-gradient(145deg,var(--color-foreground),color-mix(in_oklch,var(--color-foreground)_72%,var(--color-primary)_28%))] text-[var(--color-background)] shadow-[0_18px_40px_color-mix(in_oklch,var(--color-foreground)_16%,transparent)]">
<svg aria-hidden="true" className="size-5" fill="none" viewBox="0 0 24 24">
<path
d="M5.25 7.2c0-1.08.87-1.95 1.95-1.95h9.6c1.08 0 1.95.87 1.95 1.95v9.6c0 1.08-.87 1.95-1.95 1.95H7.2a1.95 1.95 0 0 1-1.95-1.95V7.2Z"
fill="currentColor"
opacity="0.24"
/>
<path
d="m8.25 8.85 3.2 3.2-3.2 3.2M11.55 12.05h4.2"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.9"
/>
</svg>
</span>
);
}
function NavIcon({ kind }: { kind: "overview" | "pipeline" | "messages" | "team" | "billing" }) {
switch (kind) {
case "pipeline":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<path d="M6 7.5h12M6 12h8m-8 4.5h12" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
</svg>
);
case "messages":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<path
d="M6.5 7.25h11a2 2 0 0 1 2 2v5.25a2 2 0 0 1-2 2H12l-3.9 2.85c-.6.43-1.43 0-1.43-.74V16.5H6.5a2 2 0 0 1-2-2V9.25a2 2 0 0 1 2-2Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.7"
/>
</svg>
);
case "team":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<circle cx="9" cy="9" r="2.25" stroke="currentColor" strokeWidth="1.7" />
<circle cx="16" cy="10" r="1.9" stroke="currentColor" strokeWidth="1.7" opacity="0.7" />
<path
d="M5.5 18c.35-2.55 2.22-4 4.5-4s4.15 1.45 4.5 4M14.2 18c.16-1.36.93-2.28 2.25-2.9"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.7"
/>
</svg>
);
case "billing":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<rect x="4.75" y="6.5" width="14.5" height="11" rx="2" stroke="currentColor" strokeWidth="1.7" />
<path d="M4.75 10.25h14.5" stroke="currentColor" strokeWidth="1.7" />
</svg>
);
case "overview":
default:
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<rect x="4" y="4" width="6" height="6" rx="1.5" fill="currentColor" />
<rect x="14" y="4" width="6" height="10" rx="1.5" fill="currentColor" opacity="0.75" />
<rect x="4" y="14" width="10" height="6" rx="1.5" fill="currentColor" opacity="0.65" />
</svg>
);
}
}
function SidebarNavExample() {
return (
<SidebarNav className="w-full max-w-[320px]">
<SidebarNavHeader>
<div className="space-y-5">
<div className="flex items-center gap-3">
<PatternMark />
<div>
<p
className="text-[1.55rem] font-semibold tracking-[-0.04em] text-[var(--color-foreground)]"
style={{ fontFamily: "var(--font-display)" }}
>
ScaleOps
</p>
<p className="text-sm text-[var(--color-muted-foreground)]">
Revenue command center
</p>
</div>
</div>
<div className="h-px bg-[linear-gradient(90deg,var(--color-border),transparent)]" />
</div>
</SidebarNavHeader>
<SidebarNavContent>
<SidebarNavSection>
<SidebarNavSectionLabel>Main</SidebarNavSectionLabel>
<SidebarNavItems>
<SidebarNavItem active>
<SidebarNavItemIcon>
<NavIcon kind="overview" />
</SidebarNavItemIcon>
<SidebarNavItemLabel>Dashboard</SidebarNavItemLabel>
</SidebarNavItem>
<SidebarNavItem>
<SidebarNavItemIcon>
<NavIcon kind="pipeline" />
</SidebarNavItemIcon>
<SidebarNavItemLabel>Pipeline</SidebarNavItemLabel>
</SidebarNavItem>
<SidebarNavItem>
<SidebarNavItemIcon>
<NavIcon kind="messages" />
</SidebarNavItemIcon>
<SidebarNavItemLabel>Messages</SidebarNavItemLabel>
<SidebarNavItemBadge>
<Badge size="sm" tone="success" variant="subtle">
2
</Badge>
</SidebarNavItemBadge>
</SidebarNavItem>
</SidebarNavItems>
</SidebarNavSection>
<SidebarNavSection>
<SidebarNavSectionLabel>Management</SidebarNavSectionLabel>
<SidebarNavItems>
<SidebarNavItem>
<SidebarNavItemIcon>
<NavIcon kind="team" />
</SidebarNavItemIcon>
<SidebarNavItemLabel>Team performance</SidebarNavItemLabel>
</SidebarNavItem>
<SidebarNavItem>
<SidebarNavItemIcon>
<NavIcon kind="billing" />
</SidebarNavItemIcon>
<SidebarNavItemLabel>Billing</SidebarNavItemLabel>
</SidebarNavItem>
</SidebarNavItems>
</SidebarNavSection>
</SidebarNavContent>
<SidebarNavFooter>
<div className="rounded-[1.5rem] border border-[color-mix(in_oklch,var(--color-primary)_14%,var(--color-border))] bg-[linear-gradient(160deg,color-mix(in_oklch,var(--color-primary-container)_78%,white_22%),color-mix(in_oklch,var(--color-tertiary-container)_74%,white_26%))] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Next pulse
</p>
<p className="mt-2 text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Team forecast review at 09:30
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Sales, finance, and ops owners are aligned on the board prep.
</p>
<Button className="mt-4 w-full" variant="secondary">
Open agenda
</Button>
</div>
</SidebarNavFooter>
</SidebarNav>
);
}
const meta = {
title: "Patterns/SidebarNav",
component: SidebarNavExample,
parameters: {
docs: {
description: {
component:
"SidebarNav is the reusable navigation rail pattern for dashboard and workspace shells. Use it when a product needs grouped navigation, a branded header, and a supporting footer region without improvising every rail from `Card` and `Button`."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof SidebarNavExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Anatomy: Story = {
render: () => (
<div className="grid w-[960px] gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Sidebar nav anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
The stable hooks are the rail header, grouped sections, item rows, and footer region. That
lets one product family share navigation structure without forcing every app to share the
exact same icons or footer callout.
</p>
</div>
<SidebarNavExample />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="header"</code>,
<code className="ml-1 text-[var(--color-foreground)]">data-slot="content"</code>, and
<code className="ml-1 text-[var(--color-foreground)]">data-slot="footer"</code> define the
rail frame.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="section"</code>,
<code className="ml-1 text-[var(--color-foreground)]">data-slot="item"</code>, and the
nested <code className="text-[var(--color-foreground)]">icon</code> / <code className="text-[var(--color-foreground)]">label</code> / <code className="text-[var(--color-foreground)]">badge</code> slots keep
grouped navigation predictable for styling and tests.
</p>
</div>
</div>
)
};
@@ -0,0 +1,372 @@
import { useRef, useState, type ClipboardEvent } from "react";
import {
Button,
Card,
CardContent,
Col,
Field,
FieldControl,
FieldDescription,
Input,
Label,
Row,
ValueField,
ValueFieldSuffix,
ValueFieldValue
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
const backupCode = "ORBT-7X92-KLL9-001P";
const qrPattern = [
"111111001011",
"100001101001",
"101101001111",
"101101110001",
"100001011101",
"111111001011",
"001100111101",
"110011001011",
"101010111001",
"001111000111",
"110001011001",
"101111100111"
] as const;
function CopyGlyph() {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
<rect height="8" rx="1.5" stroke="currentColor" strokeWidth="1.4" width="6.5" x="6.25" y="3.75" />
<path
d="M4.25 10.25H3.5A1.75 1.75 0 0 1 1.75 8.5v-5A1.75 1.75 0 0 1 3.5 1.75h5A1.75 1.75 0 0 1 10.25 3.5v.75"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.4"
/>
</svg>
);
}
function CornerGuide({ className }: { className?: string }) {
return (
<span
aria-hidden="true"
className={`pointer-events-none absolute h-7 w-7 border-[2px] border-[color-mix(in_oklch,var(--color-tertiary)_52%,transparent)] ${className ?? ""}`}
/>
);
}
function FakeQrCode() {
return (
<div className="relative mx-auto grid h-[12.25rem] w-[12.25rem] place-items-center">
<CornerGuide className="left-[0.35rem] top-[0.35rem] h-8 w-8 border-b-0 border-r-0 rounded-tl-[0.95rem]" />
<CornerGuide className="right-[0.35rem] top-[0.35rem] h-8 w-8 border-b-0 border-l-0 rounded-tr-[0.95rem]" />
<CornerGuide className="bottom-[0.35rem] left-[0.35rem] h-8 w-8 border-r-0 border-t-0 rounded-bl-[0.95rem]" />
<CornerGuide className="bottom-[0.35rem] right-[0.35rem] h-8 w-8 border-l-0 border-t-0 rounded-br-[0.95rem]" />
<div className="relative grid h-[9.25rem] w-[9.25rem] place-items-center rounded-[1.35rem] border border-[var(--color-border)] bg-[var(--color-surface-bright)] p-[0.72rem] shadow-[var(--shadow-sm)]">
<div className="pointer-events-none absolute left-1/2 top-1/2 h-1 w-[7.6rem] -translate-x-1/2 -translate-y-1/2 rounded-full bg-[linear-gradient(90deg,transparent,var(--color-tertiary-container),transparent)] opacity-85 shadow-[0_0_24px_color-mix(in_oklch,var(--color-tertiary)_28%,transparent)]" />
<div className="grid grid-cols-12 gap-[0.17rem] rounded-[0.95rem] bg-[var(--color-surface-bright)] p-[0.45rem]">
{qrPattern.flatMap((row, rowIndex) =>
row.split("").map((cell, cellIndex) => (
<span
aria-hidden="true"
className={
cell === "1"
? "size-[0.53rem] rounded-[0.14rem] bg-[var(--color-foreground)]"
: "size-[0.53rem] rounded-[0.14rem] bg-transparent"
}
key={`${rowIndex}-${cellIndex}`}
/>
))
)}
</div>
</div>
</div>
);
}
function VerificationTokenFields() {
const [digits, setDigits] = useState(() => Array.from({ length: 6 }, () => ""));
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
const isComplete = digits.every(Boolean);
const updateDigit = (index: number, nextValue: string) => {
const value = nextValue.replace(/\D/g, "").slice(-1);
setDigits((current) => {
const next = [...current];
next[index] = value;
return next;
});
if (value) {
inputsRef.current[index + 1]?.focus();
}
};
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
const pastedDigits = event.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6);
if (!pastedDigits) {
return;
}
event.preventDefault();
setDigits(Array.from({ length: 6 }, (_, index) => pastedDigits[index] ?? ""));
const focusIndex = Math.min(pastedDigits.length, 5);
inputsRef.current[focusIndex]?.focus();
};
return (
<Field className="gap-3" required>
<div className="flex flex-wrap items-center gap-2">
<Label requiredIndicator>Verification token</Label>
</div>
<FieldControl>
<div className="grid w-fit grid-cols-6 gap-2 sm:gap-3" onPaste={handlePaste}>
{digits.map((digit, index) => (
<Input
aria-label={`Verification digit ${index + 1}`}
className="size-10 rounded-[1rem] border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-0 text-center text-base font-semibold tracking-[0.08em] shadow-[var(--shadow-xs)] sm:size-12 [font-variant-numeric:tabular-nums]"
inputMode="numeric"
key={index}
maxLength={1}
onChange={(event) => updateDigit(index, event.target.value)}
onKeyDown={(event) => {
if (event.key === "Backspace" && !digits[index] && index > 0) {
inputsRef.current[index - 1]?.focus();
}
}}
ref={(node) => {
inputsRef.current[index] = node;
}}
required
value={digit}
/>
))}
</div>
<FieldDescription className="text-[13px] leading-5 tracking-[-0.01em] sm:whitespace-nowrap">
Paste from your authenticator app, or enter each digit manually.
</FieldDescription>
</FieldControl>
<div className="flex items-center justify-between gap-3 pt-1">
<Button className="min-w-32" disabled={!isComplete}>
Continue
</Button>
<div className="flex justify-end">
<Button variant="ghost">Cancel</Button>
</div>
</div>
</Field>
);
}
function TwoFactorSetupScene() {
const [copied, setCopied] = useState(false);
return (
<div className="relative isolate overflow-hidden rounded-[1.8rem] border border-[var(--ui-panel-border)] bg-[var(--ui-panel-bg)] shadow-[var(--ui-panel-shadow)]">
<div className="pointer-events-none absolute inset-0">
<div className="absolute left-[-2rem] top-[-1.5rem] h-32 w-32 rounded-full bg-[color-mix(in_oklch,var(--color-primary-container)_60%,transparent)] blur-3xl" />
<div className="absolute bottom-[-3rem] right-[-1rem] h-40 w-40 rounded-full bg-[color-mix(in_oklch,var(--color-tertiary-container)_66%,transparent)] blur-3xl" />
</div>
<Row className="relative" gap="none">
<Col
className="border-b border-[var(--color-border)] bg-[var(--ui-card-subtle-bg)] px-5 py-7 sm:px-6 sm:py-8 lg:border-b-0 lg:border-r"
lg={5}
span={12}
>
<div className="flex h-full min-h-[21.5rem] items-center justify-center">
<div className="grid w-full max-w-[12.25rem] justify-items-center gap-4 text-center">
<FakeQrCode />
<div className="grid w-full justify-items-center gap-2">
<h3
className="text-[1.3rem] font-semibold tracking-[-0.03em] text-[var(--color-foreground)]"
style={{ fontFamily: "var(--font-display)" }}
>
Secure protocol
</h3>
<p className="max-w-[11.75rem] text-sm leading-6 text-[var(--color-muted-foreground)]">
Position your camera within the frame to authorize this session.
</p>
</div>
</div>
</div>
</Col>
<Col className="grid gap-4 p-5 sm:p-6" lg={7} span={12}>
<div className="grid gap-2">
<div className="grid gap-1.5">
<h2
className="text-[clamp(1.9rem,3.6vw,2.7rem)] font-semibold tracking-[-0.04em] text-[var(--color-foreground)]"
style={{ fontFamily: "var(--font-display)", lineHeight: 1.02 }}
>
2FA Setup
</h2>
<p className="text-[13px] leading-5 tracking-[-0.01em] text-[var(--color-muted-foreground)] sm:whitespace-nowrap">
Scan with your authenticator app to enable Level 4 access.
</p>
</div>
</div>
<Card interactive={false} tone="subtle" className="rounded-[1.35rem] border-transparent shadow-none">
<CardContent className="grid gap-2 p-4">
<Label>Manual backup code</Label>
<ValueField className="rounded-[1.1rem]" size="lg">
<ValueFieldValue
aria-label="Manual backup code"
className="font-mono text-sm font-semibold uppercase tracking-[0.18em]"
>
{backupCode}
</ValueFieldValue>
<ValueFieldSuffix className="pr-1">
<Button
aria-label={copied ? "Backup code copied" : "Copy backup code"}
onClick={() => {
setCopied(true);
void globalThis.navigator?.clipboard?.writeText?.(backupCode);
}}
size="icon"
variant="ghost"
>
<CopyGlyph />
</Button>
</ValueFieldSuffix>
</ValueField>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
{copied
? "Copied for the current review session."
: "Use this code if you cannot scan the QR graphic."}
</p>
</CardContent>
</Card>
<Card interactive={false} tone="default" className="rounded-[1.35rem] border-transparent shadow-none">
<CardContent className="p-4">
<VerificationTokenFields />
</CardContent>
</Card>
</Col>
</Row>
</div>
);
}
function TwoFactorSetupPattern() {
return (
<div
className="min-h-screen bg-[var(--color-background)] px-4 py-8 sm:px-6 sm:py-12"
style={{ backgroundImage: "var(--ui-canvas-image)" }}
>
<div className="mx-auto w-full max-w-[42.5rem]">
<TwoFactorSetupScene />
</div>
</div>
);
}
const meta = {
title: "Patterns/TwoFactorSetup",
component: TwoFactorSetupPattern,
parameters: {
docs: {
description: {
component:
"TwoFactorSetup is a docs-only auth flow pattern, not a new base component. Use it as a composition reference when product needs a modal or setup scene for QR enrollment, backup-code fallback, and one-time-token entry. It lives under `Patterns` because the reusable pieces already exist in the system, while the flow itself is a scenario-specific arrangement."
}
},
layout: "fullscreen"
},
tags: ["autodocs"]
} satisfies Meta<typeof TwoFactorSetupPattern>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Anatomy: Story = {
render: () => (
<div
className="min-h-screen bg-[var(--color-background)] px-4 py-8 sm:px-6 sm:py-12"
style={{ backgroundImage: "var(--ui-canvas-image)" }}
>
<div className="mx-auto grid w-full max-w-[1100px] gap-5">
<div className="grid gap-2 rounded-[1.75rem] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Placement rationale
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Keep `Dialog` generic and keep this under `Patterns`. The flow is reusable as a product
scene, but not stable enough yet to justify a new public OTP or auth component contract.
</p>
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
Reused building blocks: <code className="text-[var(--color-foreground)]">Button</code>,{" "}
<code className="text-[var(--color-foreground)]">Card</code>,{" "}
<code className="text-[var(--color-foreground)]">Field</code>,{" "}
<code className="text-[var(--color-foreground)]">Input</code>, and{" "}
<code className="text-[var(--color-foreground)]">ValueField</code>.
</p>
<p>
One-off scene styling stays local to the story so the package surface does not pick up
a premature authentication abstraction.
</p>
</div>
</div>
<TwoFactorSetupScene />
</div>
</div>
)
};
export const Accessibility: Story = {
parameters: {
docs: {
description: {
story:
"Keep a manual fallback visible, label each token input clearly, and avoid using color or motion alone to communicate security state. The pattern should remain calm and readable when dropped into the existing dialog surface."
}
}
},
render: () => (
<div
className="min-h-screen bg-[var(--color-background)] px-4 py-8 sm:px-6 sm:py-12"
style={{ backgroundImage: "var(--ui-canvas-image)" }}
>
<div className="mx-auto w-full max-w-[1220px]">
<Row gap="lg">
<Col span={12} xl={5}>
<div className="grid h-full content-start gap-4 rounded-[1.75rem] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<h3 className="text-xl font-semibold tracking-[var(--tracking-tight)]">
Accessibility notes
</h3>
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>Expose a readable title and description when the scene is mounted inside `Dialog`.</p>
<p>Keep the backup code visible so the flow does not fail when scanning is unavailable.</p>
<p>Use per-digit labels and allow paste to reduce friction for keyboard and switch users.</p>
<p>
The light treatment is intentional. Security UI does not need a dark theme to feel
trustworthy.
</p>
</div>
</div>
</Col>
<Col span={12} xl={7}>
<TwoFactorSetupScene />
</Col>
</Row>
</div>
</div>
)
};
@@ -0,0 +1,170 @@
import {
Avatar,
AvatarFallback,
Badge,
Button,
Input,
InputGroup,
InputGroupPrefix,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
WorkspaceToolbar,
WorkspaceToolbarActions,
WorkspaceToolbarContent,
WorkspaceToolbarFilters,
WorkspaceToolbarLeading,
WorkspaceToolbarSearch,
WorkspaceToolbarStatus
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
function SearchGlyph() {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="5.5" stroke="currentColor" strokeWidth="1.8" />
<path d="m16 16 4 4" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
</svg>
);
}
function WorkspaceToolbarExample({
showFilters = false,
showLeading = false,
surface = "default"
}: {
showFilters?: boolean;
showLeading?: boolean;
surface?: "default" | "panel";
}) {
return (
<div className="w-full max-w-[1180px]">
<WorkspaceToolbar surface={surface}>
{showLeading ? (
<WorkspaceToolbarLeading>
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Revenue desk
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Use the toolbar for search, narrow filters, live status, and quick actions that
belong above a dense workspace, not inside the page title stack.
</p>
</WorkspaceToolbarLeading>
) : null}
<WorkspaceToolbarContent>
<WorkspaceToolbarSearch>
<InputGroup
className="w-full rounded-[1.25rem] bg-[color-mix(in_oklch,var(--color-surface)_86%,white_14%)]"
size="lg"
>
<InputGroupPrefix className="pointer-events-none">
<SearchGlyph />
</InputGroupPrefix>
<Input aria-label="Search workspace" placeholder="Search the workspace" size="lg" />
</InputGroup>
</WorkspaceToolbarSearch>
{showFilters ? (
<WorkspaceToolbarFilters>
<Select defaultValue="all">
<SelectTrigger aria-label="Filter lanes" className="w-[11rem]">
<SelectValue placeholder="Filter lanes" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All lanes</SelectItem>
<SelectItem value="ready">Ready</SelectItem>
<SelectItem value="watching">Watching</SelectItem>
</SelectContent>
</Select>
<Button variant="ghost">Reset view</Button>
</WorkspaceToolbarFilters>
) : null}
<WorkspaceToolbarStatus>
<Badge tone="primary" variant="subtle">
Live board
</Badge>
<div className="flex items-center -space-x-2">
{["MK", "DV", "LS"].map((initials, index) => (
<Avatar
className="border-2 border-[var(--color-background)]"
key={initials}
size="sm"
tone={index === 0 ? "accent" : index === 1 ? "subtle" : "default"}
>
<AvatarFallback delayMs={0}>{initials}</AvatarFallback>
</Avatar>
))}
</div>
</WorkspaceToolbarStatus>
<WorkspaceToolbarActions>
<Button variant="secondary">Export board</Button>
<Button variant="ghost">Share snapshot</Button>
</WorkspaceToolbarActions>
</WorkspaceToolbarContent>
</WorkspaceToolbar>
</div>
);
}
const meta = {
title: "Patterns/WorkspaceToolbar",
component: WorkspaceToolbarExample,
parameters: {
docs: {
description: {
component:
"WorkspaceToolbar is the shared control row for desk and workspace screens that need search, narrow filters, live status chips, and quick actions above the main page content. Use it between the outer shell and the page header instead of improvising one more flex wrapper for every workbench view."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof WorkspaceToolbarExample>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const PanelSurface: Story = {
render: () => <WorkspaceToolbarExample showFilters showLeading surface="panel" />
};
export const Anatomy: Story = {
render: () => (
<div className="grid w-[1180px] gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
<div className="grid gap-2">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Workspace toolbar anatomy
</p>
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
Keep title hierarchy in `PageHeader`. Use `WorkspaceToolbar` for operational controls
that change the current desk view without becoming the page&apos;s semantic heading.
</p>
</div>
<WorkspaceToolbarExample showFilters showLeading surface="panel" />
<div className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="leading"</code> is optional
framing copy for the current workbench or desk.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="content"</code> owns the
live control row, while <code className="text-[var(--color-foreground)]">search</code>,{" "}
<code className="text-[var(--color-foreground)]">filters</code>,{" "}
<code className="text-[var(--color-foreground)]">status</code>, and{" "}
<code className="text-[var(--color-foreground)]">actions</code> keep each cluster stable
for styling and tests.
</p>
</div>
</div>
)
};
File diff suppressed because it is too large Load Diff
+977
View File
@@ -0,0 +1,977 @@
import { useState, type ReactNode } from "react";
import {
AppShell,
AppShellBody,
AppShellFooter,
AppShellHeader,
AppShellMain,
AppShellSidebar,
Avatar,
AvatarFallback,
Badge,
Button,
Chart,
ChartDescription,
ChartHeader,
ChartHeaderAside,
ChartHeaderLeading,
ChartTitle,
PageFooter,
PageFooterActions,
PageFooterDescription,
PageFooterLeading,
PageFooterMeta,
PageFooterTitle,
PageHeader,
PageHeaderActions,
PageHeaderDescription,
PageHeaderEyebrow,
PageHeaderLeading,
PageHeaderMeta,
PageHeaderTitle,
SidebarNav,
SidebarNavContent,
SidebarNavFooter,
SidebarNavHeader,
SidebarNavItem,
SidebarNavItemBadge,
SidebarNavItemIcon,
SidebarNavItemLabel,
SidebarNavItems,
SidebarNavSection,
SidebarNavSectionLabel,
WorkspaceToolbar,
WorkspaceToolbarActions,
WorkspaceToolbarContent,
WorkspaceToolbarSearch,
WorkspaceToolbarStatus,
type ChartSeries,
type ChartTooltipContext,
Input,
InputGroup,
InputGroupPrefix,
MetricCard,
MetricCardAside,
MetricCardDelta,
MetricCardDescription,
MetricCardFooter,
MetricCardHeader,
MetricCardLabel,
MetricCardLeading,
MetricCardMedia,
MetricCardMetric,
MetricCardValue,
Progress,
Sparkbar,
StatCard,
StatCardDelta,
StatCardDescription,
StatCardHeader,
StatCardLabel,
StatCardMetric,
StatCardValue,
SegmentedControl,
SegmentedControlItem
} from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
type NavIcon =
| "dashboard"
| "transactions"
| "messages"
| "reports"
| "team"
| "customers"
| "channels"
| "orders"
| "roles"
| "billing"
| "integrations";
type ActivityVariant = "default" | "success" | "warning";
type ContributionView = "sales" | "support" | "forecast";
type SalesImpactDatum = {
gross: number;
month: string;
revenue: number;
};
type NavigationItem = {
active?: boolean;
badge?: string;
icon: NavIcon;
label: string;
};
type NavigationSection = {
items: NavigationItem[];
label: string;
};
const navigationSections: NavigationSection[] = [
{
label: "Main",
items: [
{ active: true, icon: "dashboard", label: "Dashboard" },
{ icon: "transactions", label: "Transactions" },
{ badge: "2", icon: "messages", label: "Messages" },
{ icon: "reports", label: "Reports & Analysis" },
{ icon: "team", label: "Team Performance" }
]
},
{
label: "Customers",
items: [
{ icon: "customers", label: "Customer List" },
{ icon: "channels", label: "Channels" },
{ icon: "orders", label: "Order Management" }
]
},
{
label: "Management",
items: [
{ icon: "roles", label: "Roles & Permissions" },
{ icon: "billing", label: "Billing & Subscription" },
{ icon: "integrations", label: "Integrations" }
]
}
];
const sparkBars = [
18, 24, 30, 22, 15, 12, 20, 28, 38, 46, 58, 70, 62, 48, 34, 24, 16, 18, 22, 28, 19, 14,
18, 26, 16, 12, 14, 18
] as const;
const reductionBars = [
52, 55, 58, 60, 64, 66, 68, 72, 74, 72, 70, 71, 68, 66, 64, 62, 60, 58, 56, 54
] as const;
const activityRows: Array<{
description: string;
label: string;
value: number;
variant: ActivityVariant;
}> = [
{
description: "Lead routing and personalized follow-up",
label: "Automation Models",
value: 45,
variant: "success"
},
{
description: "Deal-scoring and weighted pipeline hints",
label: "Predictive Models",
value: 30,
variant: "warning"
},
{
description: "Support Bot NLP",
label: "Language Models",
value: 15,
variant: "default"
},
{
description: "Exception watch and anomaly review",
label: "Anomaly Detection",
value: 10,
variant: "default"
}
];
const teamMembers = [
{ initials: "MK", tone: "accent" },
{ initials: "DV", tone: "subtle" },
{ initials: "LS", tone: "default" }
] as const;
const salesImpactData: SalesImpactDatum[] = [
{ gross: 52450, month: "Jan", revenue: 71280 },
{ gross: 112400, month: "Feb", revenue: 92320 },
{ gross: 98420, month: "Mar", revenue: 138260 },
{ gross: 152800, month: "Apr", revenue: 165420 },
{ gross: 82450, month: "May", revenue: 125320 },
{ gross: 136200, month: "Jun", revenue: 186540 },
{ gross: 118320, month: "Jul", revenue: 172480 },
{ gross: 121560, month: "Aug", revenue: 149860 },
{ gross: 109440, month: "Sep", revenue: 143220 },
{ gross: 141880, month: "Oct", revenue: 176340 }
];
const compactCurrencyFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 1,
notation: "compact",
style: "currency"
});
const currencyFormatter = new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 0,
style: "currency"
});
function formatCurrency(value: number) {
return currencyFormatter.format(value);
}
function formatCompactCurrency(value: number) {
return compactCurrencyFormatter.format(value);
}
const salesImpactSeries: ChartSeries<SalesImpactDatum>[] = [
{
getValue: (datum) => datum.revenue,
id: "revenue",
label: "Revenue",
style: "line-area",
tone: "primary",
valueFormatter: formatCurrency
},
{
getValue: (datum) => datum.gross,
id: "gross",
label: "Gross",
strokeDasharray: "6 8",
style: "line",
tone: "neutral",
valueFormatter: formatCurrency
}
];
function AppMark() {
return (
<span className="flex size-11 items-center justify-center rounded-[1.1rem] bg-[linear-gradient(145deg,var(--color-foreground),color-mix(in_oklch,var(--color-foreground)_72%,var(--color-primary)_28%))] text-[var(--color-background)] shadow-[0_18px_40px_color-mix(in_oklch,var(--color-foreground)_16%,transparent)]">
<svg aria-hidden="true" className="size-5" fill="none" viewBox="0 0 24 24">
<path
d="M5.25 7.2c0-1.08.87-1.95 1.95-1.95h9.6c1.08 0 1.95.87 1.95 1.95v9.6c0 1.08-.87 1.95-1.95 1.95H7.2a1.95 1.95 0 0 1-1.95-1.95V7.2Z"
fill="currentColor"
opacity="0.24"
/>
<path
d="m8.25 8.85 3.2 3.2-3.2 3.2M11.55 12.05h4.2"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.9"
/>
</svg>
</span>
);
}
function SearchGlyph() {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="5.25" stroke="currentColor" strokeWidth="1.8" />
<path d="m16 16 3.75 3.75" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
</svg>
);
}
function NavGlyph({ icon }: { icon: NavIcon }) {
switch (icon) {
case "dashboard":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<rect x="4" y="4" width="6" height="6" rx="1.5" fill="currentColor" />
<rect x="14" y="4" width="6" height="10" rx="1.5" fill="currentColor" opacity="0.75" />
<rect x="4" y="14" width="10" height="6" rx="1.5" fill="currentColor" opacity="0.65" />
</svg>
);
case "transactions":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<path
d="M6 7.5h12M6 12h8m-8 4.5h12"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.8"
/>
</svg>
);
case "messages":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<path
d="M6.5 7.25h11a2 2 0 0 1 2 2v5.25a2 2 0 0 1-2 2H12l-3.9 2.85c-.6.43-1.43 0-1.43-.74V16.5H6.5a2 2 0 0 1-2-2V9.25a2 2 0 0 1 2-2Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.7"
/>
</svg>
);
case "reports":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<path
d="M5.5 18.5h13M7.5 16V9m4 7V5m4 11v-6"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.8"
/>
</svg>
);
case "team":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<circle cx="9" cy="9" r="2.25" stroke="currentColor" strokeWidth="1.7" />
<circle cx="16" cy="10" r="1.9" stroke="currentColor" strokeWidth="1.7" opacity="0.7" />
<path
d="M5.5 18c.35-2.55 2.22-4 4.5-4s4.15 1.45 4.5 4M14.2 18c.16-1.36.93-2.28 2.25-2.9"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.7"
/>
</svg>
);
case "customers":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="8.5" r="2.75" stroke="currentColor" strokeWidth="1.7" />
<path
d="M6 18c.45-2.95 2.88-4.75 6-4.75S17.55 15.05 18 18"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.7"
/>
</svg>
);
case "channels":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<circle cx="7" cy="7" r="2" fill="currentColor" />
<circle cx="17" cy="7" r="2" fill="currentColor" opacity="0.72" />
<circle cx="12" cy="17" r="2" fill="currentColor" opacity="0.52" />
<path
d="M8.6 8.2 10.8 15m4.6-6.8-2.2 6.8M9 7h6"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.4"
/>
</svg>
);
case "orders":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<path
d="M6.75 7.25h10.5l-1.05 8.05a1.5 1.5 0 0 1-1.48 1.3H9.3a1.5 1.5 0 0 1-1.48-1.3L6.75 7.25Zm2.2 11.2a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9Zm5.9 0a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.7"
/>
</svg>
);
case "roles":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<path
d="M12 4.75 18 8v8l-6 3.25L6 16V8l6-3.25Zm0 5.1a2.15 2.15 0 1 0 0 4.3 2.15 2.15 0 0 0 0-4.3Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.7"
/>
</svg>
);
case "billing":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<rect x="4.75" y="6.5" width="14.5" height="11" rx="2" stroke="currentColor" strokeWidth="1.7" />
<path d="M4.75 10.25h14.5" stroke="currentColor" strokeWidth="1.7" />
</svg>
);
case "integrations":
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 24 24">
<circle cx="7" cy="12" r="2.1" fill="currentColor" />
<circle cx="17" cy="7" r="2.1" fill="currentColor" opacity="0.72" />
<circle cx="17" cy="17" r="2.1" fill="currentColor" opacity="0.52" />
<path
d="M9 11.2 14.8 7.8M9 12.8l5.8 3.4"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
/>
</svg>
);
}
}
function DeltaBadge({
children,
className,
tone = "success"
}: {
children: ReactNode;
className?: string;
tone?: "primary" | "success";
}) {
return (
<Badge
className={className}
size="sm"
tone={tone}
variant="subtle"
>
{children}
</Badge>
);
}
function SalesImpactTooltip({
activeLabel,
series
}: ChartTooltipContext<SalesImpactDatum>) {
return (
<div className="grid gap-3">
<div className="text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
{activeLabel}
</div>
<div className="grid gap-2">
{series.map((entry) => (
<div
className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 text-sm"
key={entry.id}
>
<span
className="size-2.5 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-[var(--color-muted-foreground)]">{entry.label}</span>
<span className="font-medium text-[var(--color-foreground)]">
{entry.formattedValue}
</span>
</div>
))}
</div>
</div>
);
}
function RevenueSidebar() {
return (
<SidebarNav className="h-full">
<SidebarNavHeader>
<div className="space-y-5">
<div className="flex items-center gap-3">
<AppMark />
<div>
<p
className="text-[1.55rem] font-semibold tracking-[-0.04em] text-[var(--color-foreground)]"
style={{ fontFamily: "var(--font-display)" }}
>
ScaleOps
</p>
<p className="text-sm text-[var(--color-muted-foreground)]">
Revenue command center
</p>
</div>
</div>
<div className="h-px bg-[linear-gradient(90deg,var(--color-border),transparent)]" />
</div>
</SidebarNavHeader>
<SidebarNavContent>
{navigationSections.map((section) => (
<SidebarNavSection key={section.label}>
<SidebarNavSectionLabel>{section.label}</SidebarNavSectionLabel>
<SidebarNavItems>
{section.items.map((item) => (
<SidebarNavItem active={item.active} key={item.label}>
<SidebarNavItemIcon>
<NavGlyph icon={item.icon} />
</SidebarNavItemIcon>
<SidebarNavItemLabel>{item.label}</SidebarNavItemLabel>
{item.badge ? (
<SidebarNavItemBadge>
<Badge
className={item.active ? "bg-white/12 text-white" : ""}
size="sm"
tone="success"
variant="subtle"
>
{item.badge}
</Badge>
</SidebarNavItemBadge>
) : null}
</SidebarNavItem>
))}
</SidebarNavItems>
</SidebarNavSection>
))}
</SidebarNavContent>
<SidebarNavFooter>
<div className="mt-auto rounded-[1.5rem] border border-[color-mix(in_oklch,var(--color-primary)_14%,var(--color-border))] bg-[linear-gradient(160deg,color-mix(in_oklch,var(--color-primary-container)_78%,white_22%),color-mix(in_oklch,var(--color-tertiary-container)_74%,white_26%))] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Next pulse
</p>
<p className="mt-2 text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Team forecast review at 09:30
</p>
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
Sales, finance, and ops owners are all on pace for the weekly board.
</p>
<Button className="mt-4 w-full" variant="secondary">
Open agenda
</Button>
</div>
</SidebarNavFooter>
</SidebarNav>
);
}
function Toolbar() {
return (
<WorkspaceToolbar>
<WorkspaceToolbarContent>
<WorkspaceToolbarSearch className="xl:max-w-[34rem]">
<InputGroup
className="w-full rounded-[1.25rem] bg-[color-mix(in_oklch,var(--color-surface)_86%,white_14%)]"
size="lg"
>
<InputGroupPrefix className="pointer-events-none">
<SearchGlyph />
</InputGroupPrefix>
<Input
aria-label="Search dashboard"
placeholder="Type here..."
size="lg"
/>
</InputGroup>
</WorkspaceToolbarSearch>
<WorkspaceToolbarStatus>
<DeltaBadge tone="primary">Live board</DeltaBadge>
<div className="flex items-center -space-x-2">
{teamMembers.map((member) => (
<Avatar
key={member.initials}
className="border-2 border-[var(--color-background)]"
size="sm"
tone={member.tone}
>
<AvatarFallback delayMs={0}>{member.initials}</AvatarFallback>
</Avatar>
))}
</div>
</WorkspaceToolbarStatus>
<WorkspaceToolbarActions>
<Button variant="secondary">Export board</Button>
</WorkspaceToolbarActions>
</WorkspaceToolbarContent>
</WorkspaceToolbar>
);
}
function DashboardFooter() {
return (
<PageFooter className="bg-[color-mix(in_oklch,var(--color-surface)_88%,white_12%)]" tone="subtle">
<PageFooterLeading>
<PageFooterMeta>
<DeltaBadge tone="primary">Board synced</DeltaBadge>
<Badge tone="success" variant="outline">
Forecast live
</Badge>
</PageFooterMeta>
<PageFooterTitle>
Revenue command center is ready for the weekly board review.
</PageFooterTitle>
<PageFooterDescription>
Assisted routing, forecast activity, and anomaly monitoring are all updated from the
same operating window.
</PageFooterDescription>
</PageFooterLeading>
<PageFooterActions>
<Button variant="ghost">Open audit log</Button>
<Button variant="secondary">Share snapshot</Button>
</PageFooterActions>
</PageFooter>
);
}
function SalesGrowthCard() {
return (
<MetricCard interactive tone="hero">
<MetricCardHeader>
<MetricCardLeading>
<div className="flex items-center gap-3">
<span className="flex size-11 items-center justify-center rounded-[1rem] bg-[color-mix(in_oklch,var(--color-warning)_65%,white_35%)] text-[var(--color-foreground)]">
<svg aria-hidden="true" className="size-5" fill="none" viewBox="0 0 24 24">
<path
d="M7 15.5 9.75 9l2.15 4.15 1.95-2.8L17 15.5"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
/>
</svg>
</span>
<div>
<p className="text-sm font-medium text-white/78">Sales Growth</p>
<p className="text-sm text-white/56">Commercial momentum this month</p>
</div>
</div>
</MetricCardLeading>
<MetricCardAside>
<MetricCardDelta tone="primary">+$12,180</MetricCardDelta>
</MetricCardAside>
</MetricCardHeader>
<MetricCardMetric>
<MetricCardValue>$101,820</MetricCardValue>
<MetricCardDelta tone="success">+84</MetricCardDelta>
</MetricCardMetric>
<MetricCardDescription className="max-w-sm">
The mix is being lifted by automated follow-up, better route timing, and steadier
close velocity in the enterprise lane.
</MetricCardDescription>
<MetricCardMedia tone="hero">
<Sparkbar
className="rounded-[1.35rem] bg-white/6 px-4 py-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.09)]"
columns={14}
height={70}
highlightRange={[9, 13]}
tone="contrast"
values={sparkBars}
variant="success"
/>
</MetricCardMedia>
</MetricCard>
);
}
function CostReductionCard() {
return (
<MetricCard interactive>
<MetricCardHeader>
<MetricCardLeading>
<div className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-[0.95rem] bg-[color-mix(in_oklch,var(--color-surface-container-highest)_78%,white_22%)] text-[var(--color-foreground)]">
<svg aria-hidden="true" className="size-4.5" fill="none" viewBox="0 0 24 24">
<path
d="M8 8.75h8M8 12h8M8 15.25h4"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.8"
/>
</svg>
</span>
<MetricCardLabel>Operational Cost Reduction</MetricCardLabel>
</div>
</MetricCardLeading>
<MetricCardAside>
<MetricCardDelta tone="primary">42%</MetricCardDelta>
</MetricCardAside>
</MetricCardHeader>
<MetricCardMetric>
<MetricCardValue>$38,250</MetricCardValue>
<MetricCardDelta tone="success">+78</MetricCardDelta>
</MetricCardMetric>
<MetricCardDescription>
Lower servicing overhead through assisted routing and tighter triage.
</MetricCardDescription>
<MetricCardMedia>
<Sparkbar height={74} highlightRange={[0, 12]} values={reductionBars} />
</MetricCardMedia>
<MetricCardFooter>
<div className="flex items-center justify-between text-sm text-[var(--color-muted-foreground)]">
<span>Quarter target</span>
<span>$101,820</span>
</div>
<Progress tone="subtle" value={42} variant="success" />
</MetricCardFooter>
</MetricCard>
);
}
function SalesImpactCard() {
return (
<Chart
className="self-start"
data={salesImpactData}
defaultActiveIndex={4}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
header={
<ChartHeader>
<ChartHeaderLeading>
<ChartTitle>Sales Impact</ChartTitle>
<ChartDescription>
Revenue lift versus gross pipeline over the current operating arc.
</ChartDescription>
</ChartHeaderLeading>
<ChartHeaderAside>
<DeltaBadge tone="primary">Live forecast</DeltaBadge>
</ChartHeaderAside>
</ChartHeader>
}
height={336}
renderTooltip={SalesImpactTooltip}
series={salesImpactSeries}
showActiveGuide={false}
showYAxis={false}
title="Sales Impact"
yAxisValueFormatter={formatCompactCurrency}
/>
);
}
function ActivityBreakdownCard() {
return (
<MetricCard interactive>
<MetricCardHeader>
<MetricCardLabel>AI Model Activity Breakdown</MetricCardLabel>
</MetricCardHeader>
<MetricCardDescription>
The active mix remains automation-heavy, with anomaly review intentionally
constrained until the current forecast stabilizes.
</MetricCardDescription>
<MetricCardMedia tone="subtle">
<div className="grid gap-4">
{activityRows.map((row) => (
<div key={row.label} className="grid gap-2.5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-[var(--color-foreground)]">{row.label}</p>
<p className="text-xs text-[var(--color-muted-foreground)]">{row.description}</p>
</div>
<span className="text-sm font-medium text-[var(--color-foreground)]">
{row.value}%
</span>
</div>
<Progress tone="subtle" value={row.value} variant={row.variant} />
</div>
))}
</div>
</MetricCardMedia>
<MetricCardFooter className="border-none pt-0">
<div className="rounded-[1.35rem] bg-[linear-gradient(160deg,color-mix(in_oklch,var(--color-primary-container)_58%,white_42%),color-mix(in_oklch,var(--color-surface)_82%,white_18%))] p-4">
<p className="text-sm leading-6 text-[var(--color-foreground)]">
Automation is doing the visible volume work, but the real stability signal is the
low anomaly load across the same operating window.
</p>
</div>
</MetricCardFooter>
</MetricCard>
);
}
function ContributionPanel({ view }: { view: ContributionView }) {
switch (view) {
case "sales":
return (
<div className="flex flex-wrap gap-2">
<Badge tone="primary">Lead scoring</Badge>
<Badge tone="primary" variant="outline">
Predictive sales
</Badge>
<Badge tone="success" variant="outline">
Follow-up assist
</Badge>
</div>
);
case "support":
return (
<div className="rounded-[1.45rem] border border-[var(--color-border)] bg-[color-mix(in_oklch,var(--color-card)_84%,white_16%)] p-4">
<p className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Queue handling is faster without increasing escalations.
</p>
<p className="mt-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
Summaries, classification, and priority hints reduce review time while keeping
human handoff visible for the team lead.
</p>
</div>
);
case "forecast":
return (
<div className="rounded-[1.45rem] border border-[var(--color-border)] bg-[color-mix(in_oklch,var(--color-card)_84%,white_16%)] p-4">
<p className="text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]">
Forecast confidence is healthy, but the board is still watching conversion
spread in the mid-market segment.
</p>
<p className="mt-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
Current guidance is strong enough for commercial planning, not strong enough to
stop watching the anomaly lane.
</p>
</div>
);
}
}
function ContributionCard() {
const [view, setView] = useState<ContributionView>("sales");
return (
<MetricCard
className="overflow-hidden border-[color-mix(in_oklch,var(--color-primary)_18%,var(--color-border))] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%),color-mix(in_oklch,var(--color-primary-container)_26%,var(--color-card)))]"
>
<MetricCardHeader className="grid gap-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<MetricCardLabel>AI Contribution Overview</MetricCardLabel>
<MetricCardDescription>
Assisted work is improving the quality of each commercial pass instead of only
adding more volume.
</MetricCardDescription>
</div>
<div className="flex flex-col items-start gap-3 sm:items-end">
<Badge tone="primary" variant="solid">
AI + Revenue
</Badge>
<SegmentedControl
aria-label="Contribution lens"
onValueChange={(nextValue) => setView(nextValue as ContributionView)}
value={view}
>
<SegmentedControlItem value="sales">Sales</SegmentedControlItem>
<SegmentedControlItem value="support">Support</SegmentedControlItem>
<SegmentedControlItem value="forecast">Forecast</SegmentedControlItem>
</SegmentedControl>
</div>
</div>
</MetricCardHeader>
<MetricCardMedia padding="flush" tone="accent">
<div className="grid gap-5">
{view === "sales" ? (
<StatCard className="shadow-none">
<StatCardHeader>
<StatCardLabel>Assisted revenue influence</StatCardLabel>
</StatCardHeader>
<StatCardMetric>
<StatCardValue>31%</StatCardValue>
<StatCardDelta tone="success">+9.2%</StatCardDelta>
</StatCardMetric>
<StatCardDescription>
AI-enhanced lead processing, automated follow-up, and route suggestions are
improving accuracy while giving the team more time for high-leverage deals.
</StatCardDescription>
</StatCard>
) : null}
<ContributionPanel view={view} />
</div>
</MetricCardMedia>
<MetricCardFooter className="border-none pt-0">
<div className="flex flex-wrap items-center justify-between gap-4 rounded-[1.35rem] bg-[color-mix(in_oklch,var(--color-surface)_86%,white_14%)] px-4 py-3">
<div className="flex items-center -space-x-2">
{teamMembers.map((member) => (
<Avatar
key={`contribution-${member.initials}`}
className="border-2 border-[var(--color-background)]"
size="sm"
tone={member.tone}
>
<AvatarFallback delayMs={0}>{member.initials}</AvatarFallback>
</Avatar>
))}
</div>
<Button variant="ghost">Review forecast</Button>
</div>
</MetricCardFooter>
</MetricCard>
);
}
function RevenueDashboardScene() {
return (
<div className="min-h-screen bg-[var(--color-background)] px-4 py-6 text-[var(--color-foreground)] sm:px-6 lg:px-8">
<div className="mx-auto max-w-[1500px]">
<AppShell className="relative" layout="sidebar" surface="panel">
<div className="pointer-events-none absolute inset-0">
<div className="motion-drift absolute -left-10 top-12 h-40 w-40 rounded-full bg-[color-mix(in_oklch,var(--color-primary-container)_54%,transparent)] blur-3xl" />
<div className="motion-drift absolute right-0 top-0 h-32 w-32 rounded-full bg-[color-mix(in_oklch,var(--color-tertiary-container)_58%,transparent)] blur-3xl" />
<div className="motion-breathe absolute bottom-8 right-16 h-36 w-36 rounded-full bg-[color-mix(in_oklch,var(--color-primary)_12%,transparent)] blur-3xl" />
</div>
<AppShellSidebar>
<RevenueSidebar />
</AppShellSidebar>
<AppShellBody>
<AppShellHeader>
<Toolbar />
</AppShellHeader>
<AppShellMain>
<section className="grid gap-4">
<PageHeader align="end" variant="hero">
<PageHeaderLeading>
<PageHeaderMeta>
<PageHeaderEyebrow>Friday, Dec 5, 2025</PageHeaderEyebrow>
<DeltaBadge tone="primary">Calm pulse</DeltaBadge>
</PageHeaderMeta>
<PageHeaderTitle>Welcome back, Nathan.</PageHeaderTitle>
<PageHeaderDescription>
Revenue operations is holding a calm pulse today. Growth is still being
led by AI-assisted follow-up, while cost pressure stays within the board
target window.
</PageHeaderDescription>
</PageHeaderLeading>
<PageHeaderActions>
<Button>Morning brief</Button>
<Button variant="secondary">Watch anomalies</Button>
</PageHeaderActions>
</PageHeader>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<SalesGrowthCard />
<CostReductionCard />
</div>
</section>
<section className="grid items-start gap-4 xl:grid-cols-[minmax(0,1.25fr)_minmax(0,0.75fr)]">
<SalesImpactCard />
<div className="grid gap-4">
<ActivityBreakdownCard />
<ContributionCard />
</div>
</section>
</AppShellMain>
<AppShellFooter>
<DashboardFooter />
</AppShellFooter>
</AppShellBody>
</AppShell>
</div>
</div>
);
}
const meta = {
title: "Scenes/Revenue Dashboard",
component: RevenueDashboardScene,
parameters: {
docs: {
description: {
component:
"Revenue Dashboard is a composition scene that demonstrates how the existing Cadence UI components can assemble a polished analytics workspace with a sidebar, KPI slabs, chart surfaces, and AI summary panels without adding a parallel component set."
}
},
layout: "fullscreen"
},
tags: ["autodocs"]
} satisfies Meta<typeof RevenueDashboardScene>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
@@ -0,0 +1,83 @@
# Harness Control Plane Hardening
- Status: `in-progress`
- Owner: `sisyphus`
- Date: `2026-03-24`
## Goal
Finish the next harness-engineering hardening slice by making changed-suite selection authoritative in
CI, promoting harness artifacts to first-class debugging outputs, centralizing browser and a11y
coverage under one explicit contract, and turning orchestration from a thin wrapper into a safer
repo capability with its own tests and docs.
## Scope
- In scope:
- broaden harness selection so high-risk control-plane and release changes escalate to the right
validation gate
- make CI run the selected harness suites instead of always forcing the PR gate
- upload harness, a11y, and browser test artifacts from CI
- replace duplicated story coverage lists with one shared harness coverage contract
- improve orchestration binary discovery, preflight checks, and plan-linked dispatch support
- add tests for harness control-plane helpers and update docs to match the implementation
- Out of scope:
- redesigning the component library or Storybook content beyond harness metadata needs
- replacing the external orchestration engine itself
- introducing visual snapshot infrastructure or hosted browser grids
## Constraints
- Preserve the existing `pnpm harness:*` commands as the main public interface.
- Keep package-first release validation as the canonical release path.
- Reuse execution plans as the source of orchestration intent instead of creating a parallel plan
format.
- Keep new harness checks targeted so CI cost stays proportional to risk.
## Affected Surfaces
- `scripts/harness/*`
- `tests/e2e/*`
- `.github/workflows/harness-validate.yml`
- `package.json`
- `vitest.config.ts`
- `docs/harness-engineering.md`
- `docs/orchestration.md`
- `README.md`
- `CONTRIBUTING.md`
## Plan
1. Refactor harness suite selection so release-risk and harness-risk changes escalate beyond
static checks and let `changed` execution become the CI entrypoint.
2. Promote `.artifacts/*` outputs into uploaded CI artifacts and make changed-suite execution log
its selected suites clearly.
3. Introduce one shared harness coverage contract for Storybook browser smoke and a11y checks,
then move both runners onto it.
4. Add orchestration preflight and plan-linked dispatch helpers so execution plans directly support
orchestration runs.
5. Add control-plane tests plus doc updates so harness behavior, workflow guidance, and public
commands stay aligned.
## Validation
- `pnpm exec vitest run scripts/harness/**/*.test.ts`
- `pnpm harness:suites`
- `pnpm harness:select -- --json --changed-file scripts/harness/core.mjs`
- `pnpm harness:validate:changed -- --dry-run --changed-file scripts/harness/core.mjs`
- `pnpm test:e2e:a11y`
- `pnpm test:e2e:smoke`
- `pnpm harness:validate:pr`
## Orchestration Task Sketch
- `T1`: harden suite selection and CI execution flow
- `T2`: centralize browser and a11y coverage contract
- `T3`: improve orchestration preflight and plan-linked dispatch
- `T4`: add harness tests and reconcile docs
## Status Log
- `2026-03-24 18:40` reviewed harness docs, scripts, CI wiring, and recent release hardening work;
confirmed the main gaps are authoritative suite selection, CI artifact visibility, explicit
browser coverage ownership, orchestration preflight, and missing control-plane tests
@@ -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
@@ -0,0 +1,62 @@
# Challenge Progress Pattern
- Status: `completed`
- Owner: `Codex`
- Date: `2026-03-25`
## Goal
Add a reusable `ChallengeProgress` pattern to `packages/ui` that recreates the stacked challenge
target panel shown in the reference, while staying inside Cadence UI's own tonal, rounded, and
component-first visual language.
## Scope
- In scope:
- add a new `ChallengeProgress` pattern for multi-row target progress panels
- compose the pattern from existing Cadence UI primitives where practical, especially `Badge`,
`Progress`, and `Spinner`
- add tests and Storybook docs for the new pattern
- export the pattern from `@ai-ui/ui`
- Out of scope:
- changing the base `Progress` contract or segment-count limits
- adding a new shared icon package or icon primitives
- registry metadata refresh while the worktree already carries unrelated registry changes
## Constraints
- Keep the pattern visually aligned with `DESIGN.md`, not the dark reference palette.
- Prefer stable slots and data attributes over story-local styling glue.
- Preserve the dirty worktree and avoid rewriting unrelated in-flight edits.
- Use the smallest new API surface that can still support multiple challenge rows.
## Affected Surfaces
- `packages/ui/src/patterns/challenge-progress.tsx`
- `packages/ui/src/patterns/challenge-progress.variants.ts`
- `packages/ui/src/patterns/challenge-progress.test.tsx`
- `packages/ui/src/index.ts`
- `apps/docs/src/patterns/challenge-progress.stories.tsx`
## Plan
1. Define the pattern API and slot structure around a title plus repeated challenge rows.
2. Implement the pattern styling with existing tokens and compose row status from existing
primitives.
3. Add unit coverage and Storybook docs showing the reference-inspired layout in Cadence UI's own
palette.
4. Run targeted validation for the new pattern, exports, and docs surface.
## Validation
- `pnpm --filter @ai-ui/ui typecheck`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/patterns/challenge-progress.test.tsx --config ../../vitest.config.ts`
- `pnpm exec eslint apps/docs/src/patterns/challenge-progress.stories.tsx packages/ui/src/patterns/challenge-progress.tsx packages/ui/src/patterns/challenge-progress.variants.ts packages/ui/src/patterns/challenge-progress.test.tsx packages/ui/src/index.ts`
- `pnpm harness:validate:docs`
## Status Log
- `2026-03-25 19:44` Read the required system-of-record files, inspected the existing `Progress`, `Badge`, `Spinner`, and pattern authoring conventions, and scoped the work as a new reusable pattern instead of a one-off story.
- `2026-03-25 20:20` Implemented the `ChallengeProgress` pattern and variants around a title plus repeated challenge rows, composing row chips from `Badge`, row meters from segmented `Progress`, and loading affordances from `Spinner`.
- `2026-03-25 20:24` Added unit coverage, Storybook docs, and package exports for the new pattern.
- `2026-03-25 20:26` Verified `pnpm --filter @ai-ui/ui typecheck`, targeted `vitest`, targeted `eslint`, and `pnpm harness:validate:docs`.
@@ -0,0 +1,77 @@
# Chart Pattern
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Add a reusable `Chart` pattern to `@ai-ui/ui` that can render the kind of dashboard trend panel
shown in the supplied reference: multi-series lines, soft area shading, an active point summary,
and a composable card-like review surface that still follows Cadence UI's current visual language.
## Scope
- In scope:
- add a new `Chart` component to `packages/ui`
- keep the first release focused on cartesian trend charts for dashboard analytics surfaces
- support multi-series rendering, optional area fills, axes, legend, and active-point callouts
- add Storybook stories, including a `Sales Impact`-style showcase
- add unit coverage for the public contract and interactive active-point behavior
- refresh repo status docs to reflect the shipped pattern
- Out of scope:
- introducing a third-party charting dependency
- building a full visualization framework with bars, pies, heatmaps, and pivoting
- server-driven drilldown, zooming, panning, or data transforms outside the component
- adding the new story to the curated browser harness unless it becomes a required regression
surface
## Constraints
- Follow `DESIGN.md` over the raw screenshot when visual details conflict.
- Reuse existing tokens, motion rules, and card-like surface styling instead of hardcoded brand
treatments.
- Keep the API source-owned and narrow, similar to the `DataTable` philosophy.
- Preserve stable `data-slot` and `data-*` hooks so docs and tests can target the component
predictably.
## Affected Surfaces
- `docs/exec-plans/2026-03-25-chart-pattern.md`
- `packages/ui/src/components/chart.tsx`
- `packages/ui/src/components/chart.variants.ts`
- `packages/ui/src/components/chart.test.tsx`
- `packages/ui/src/index.ts`
- `apps/docs/src/components/chart.stories.tsx`
- `README.md`
- `roadmap.md`
- `registry/index.json`
## Plan
1. Add an execution plan and update repo status docs for the new public pattern.
2. Implement a source-owned `Chart` component that supports multi-series dashboard trend charts
without adding an external chart library.
3. Add Storybook stories that explain anatomy and demonstrate a `Sales Impact`-style analytics
panel.
4. Add unit tests for slots, legend rendering, active-point updates, and controlled state.
5. Run the narrowest useful validation suites, then record any remaining gaps.
## Validation
- `pnpm test -- --runInBand`
- `pnpm harness:validate:docs`
- `pnpm registry:build`
## Orchestration Task Sketch
- `T1`: implement the Chart component and export surface
- `T2 -> T1`: add docs stories and validate the rendered showcase
- `T3 -> T1`: add tests and registry updates
## Status Log
- `2026-03-25 19:08` Read the system-of-record files and confirmed the repo does not yet ship a chart pattern.
- `2026-03-25 19:18` Started the chart implementation plan for a new dashboard-focused analytics pattern.
- `2026-03-25 19:42` Implemented the new `Chart` component, public exports, stories, tests, and status-doc updates.
- `2026-03-25 19:55` Validated `packages/ui` typecheck, targeted chart tests, package builds, Storybook docs build, and registry generation. `pnpm harness:validate:changed` still failed because the dirty worktree already contains unrelated `apps/docs/src/revenue-dashboard.stories.tsx` type errors and existing `react-refresh/only-export-components` warnings in `packages/ui/src/components/context-menu.tsx`.
@@ -0,0 +1,89 @@
# Component Polish Pass
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Run a focused polish pass across six existing component surfaces so the current Material runtime
direction feels more intentional in high-traffic docs and product-like stories. The goal is not to
add new APIs. The goal is to tighten hierarchy, depth, and interaction feedback while keeping the
existing contract stable.
## Scope
- In scope:
- refine `StatCard` hierarchy, default interaction treatment, and delta emphasis
- refine `DataTable` row, header, toolbar, and selection presentation
- refine `EmptyState` composition, especially the `split` layout media and narrative weight
- refine `Command`, `Combobox`, and `Select` list item selection and active-state treatment
- refine `MetricCard` cohesion across media, footer, and CTA regions
- refine base `Card` interactive behavior so general business cards feel less static
- update the closest Storybook stories and tests where behavior or reviewability changes
- Out of scope:
- adding new component families or expanding public props beyond what the current contract needs
- introducing a second motion language or non-token visual escape hatches
- broad scene rewrites outside the stories that best demonstrate the updated behavior
## Constraints
- Follow the active visual and motion direction in `DESIGN.md`.
- Preserve reduced-motion safety and the current component contract.
- Keep write scopes isolated so six workers can operate in parallel without conflicting.
- Keep shared integration surfaces on the main thread where practical.
- Do not revert or overwrite unrelated in-progress repo changes.
## Affected Surfaces
- `packages/ui/src/components/card*`
- `packages/ui/src/components/stat-card*`
- `packages/ui/src/components/data-table*`
- `packages/ui/src/components/empty-state*`
- `packages/ui/src/components/command*`
- `packages/ui/src/components/combobox*`
- `packages/ui/src/components/select*`
- `packages/ui/src/components/metric-card*`
- `apps/docs/src/components/card.stories.tsx`
- `apps/docs/src/components/stat-card.stories.tsx`
- `apps/docs/src/components/data-table.stories.tsx`
- `apps/docs/src/components/empty-state.stories.tsx`
- `apps/docs/src/components/command.stories.tsx`
- `apps/docs/src/components/combobox.stories.tsx`
- `apps/docs/src/components/select.stories.tsx`
- `apps/docs/src/components/metric-card.stories.tsx`
- `docs/exec-plans`
## Plan
1. Write and commit an execution plan for the six-slice polish pass so delegation stays resumable.
2. Dispatch six isolated implementation workers, one per polish slice, each owning its component,
nearby stories, and nearby tests only.
3. Review worker diffs in the main thread, resolve any cross-slice visual inconsistencies, and keep
shared integration concerns centralized.
4. Run the narrowest useful harness and component validation suites, then fix any regressions in the
main thread.
5. Summarize what changed, what was validated, and any remaining gaps.
## Validation
- `pnpm harness:validate:component`
- `pnpm harness:validate:docs`
- `pnpm harness:validate:changed`
## Orchestration Task Sketch
- `T1`: polish `StatCard`
- `T2`: polish `DataTable`
- `T3`: polish `EmptyState`
- `T4`: polish `Command`, `Combobox`, and `Select`
- `T5`: polish `MetricCard`
- `T6`: polish base `Card`
- `T7 -> T1,T2,T3,T4,T5,T6`: integrate worker results and run focused validation
## Status Log
- `2026-03-25 16:26` started the six-slice polish pass after reviewing the current design language, component implementations, and representative Storybook stories
- `2026-03-25 16:29` wrote the execution plan and prepared six isolated worker slices plus a main-thread integration pass
- `2026-03-25 16:38` completed the delegated `Card`, `StatCard`, `EmptyState`, `MetricCard`, `Command/Combobox/Select`, and `DataTable` slices and reviewed the combined result set in the main thread
- `2026-03-25 16:41` validated the combined changes with targeted Vitest coverage (`card`, `stat-card`, `empty-state`, `metric-card`, `command`, `combobox`, `select`, `data-table`), `pnpm build:docs`, `pnpm --filter @ai-ui/ui exec tsc --noEmit`, and `pnpm --dir apps/docs exec tsc --noEmit`
@@ -0,0 +1,71 @@
# Dashboard Contract Hardening
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Harden the new dashboard-oriented component contracts so the `Revenue Dashboard` scene no longer
needs to bypass them for high-emphasis KPI cards or for custom chart header layouts.
## Scope
- In scope:
- extend `MetricCard` with high-emphasis tones suitable for dark or hero KPI panels
- add structured `MetricCard` header slots for leading and aside content
- make the `Chart` header composable without breaking the current convenience props
- refresh docs stories, tests, and the revenue dashboard consumer to use the stronger contracts
- Out of scope:
- introducing a full dashboard layout or navigation system
- expanding the chart surface beyond the existing cartesian trend pattern
- extracting new sparkline or sparkbar primitives in this slice
## Constraints
- Preserve the current Material-inspired design direction in `DESIGN.md`.
- Keep the API additive and source-owned instead of replacing existing stories with one-off markup.
- Preserve the stable slot and `data-*` review surface so tests and docs stay predictable.
- Keep the current `title` / `description` / `value` / `valueChange` chart props working while
adding the more composable header path.
## Affected Surfaces
- `docs/exec-plans`
- `packages/ui/src/components/metric-card.tsx`
- `packages/ui/src/components/metric-card.variants.ts`
- `packages/ui/src/components/chart.tsx`
- `packages/ui/src/components/chart.variants.ts`
- `packages/ui/src/index.ts`
- `packages/ui/src/components/*.test.tsx`
- `apps/docs/src/components/*.stories.tsx`
- `apps/docs/src/revenue-dashboard.stories.tsx`
## Plan
1. Extend `MetricCard` with `inverse` and `hero` visual tones plus stable `leading` and `aside`
header slots.
2. Refactor `Chart` to accept a composed header while keeping the current prop-based header API as
a convenience fallback.
3. Update stories and the revenue dashboard scene so the new contracts are exercised by real
compositions instead of only isolated tests.
4. Run focused component and docs validation, then record any residual issues.
## Validation
- `pnpm --filter @ai-ui/ui test -- metric-card chart`
- `pnpm --filter @ai-ui/ui typecheck`
- `pnpm build:docs`
## Orchestration Task Sketch
- `T1`: harden `MetricCard` tone and header composition contract
- `T2 -> T1`: harden `Chart` header composition contract
- `T3 -> T2`: refresh stories, revenue dashboard usage, and focused validation
## Status Log
- `2026-03-25 20:36` started the contract-hardening slice after reviewing the revenue dashboard and confirming that `Sales Growth` and `Sales Impact` still rely on API workarounds rather than first-class component affordances
- `2026-03-25 20:49` extended `MetricCard` with `inverse` and `hero` tones plus `leading` and `aside` header slots, then refactored the docs story and revenue dashboard consumer to use them
- `2026-03-25 20:56` refactored `Chart` to support a composed header path while preserving the legacy prop-driven header contract, and updated stories and tests to cover both paths
- `2026-03-25 20:59` validated focused `MetricCard` and `Chart` tests plus `build:docs`; `pnpm --filter @ai-ui/ui typecheck` is still blocked by unrelated pre-existing `packages/ui/src/components/sparkbar.tsx` type errors outside this slice
@@ -0,0 +1,82 @@
# Foundation And Pattern Motion Pass
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Run a focused third-pass polish on four remaining motion gaps: overly busy `DataTable` chrome,
under-animated foundation surfaces, static-feeling workspace patterns, and missing docs/test
coverage for `StatCard` / `MetricCard` motion behavior.
## Scope
- In scope:
- reduce excess hover lift around `DataTable` selection, search, and pagination chrome while
preserving row hover and sort-state feedback
- add restrained component-specific polish to `Alert`, `Avatar`, and `Badge`
- bring `PageHeader`, `AppShell`, and `PageFooter` closer to the existing pattern motion quality
bar set by `SidebarNav`
- add dedicated motion stories and targeted regression tests for `StatCard` and `MetricCard`
- Out of scope:
- adding new animation dependencies
- broad visual redesign unrelated to motion and interaction feedback
- touching unrelated in-progress component work
## Constraints
- Respect `DESIGN.md` and keep one calm, spatial motion language.
- Keep write scopes isolated so workers do not collide.
- Preserve existing public APIs unless a very small compatibility-safe addition is justified.
- Do not revert unrelated work already present in the dirty worktree.
## Ownership
- Main thread:
- plan, review, integration, and validation
- Worker 1:
- `packages/ui/src/components/data-table*`
- `apps/docs/src/components/data-table.stories.tsx`
- Worker 2:
- `packages/ui/src/components/alert*`
- `packages/ui/src/components/avatar*`
- `packages/ui/src/components/badge*`
- nearest owned stories/tests if needed
- Worker 3:
- `packages/ui/src/patterns/page-header*`
- `packages/ui/src/patterns/app-shell*`
- `packages/ui/src/patterns/page-footer*`
- nearest owned stories/tests if needed
- Worker 4:
- `packages/ui/src/components/stat-card*`
- `packages/ui/src/components/metric-card*`
- `apps/docs/src/components/stat-card.stories.tsx`
- `apps/docs/src/components/metric-card.stories.tsx`
## Plan
1. Write the execution plan and confirm the four disjoint ownership slices.
2. Dispatch four workers with isolated write scopes and keep main-thread ownership limited to
review, integration, and validation.
3. Review the returned slices, fix any integration gaps, and avoid broad shared-file churn.
4. Run focused tests and typechecks for the affected components and docs surfaces.
## Validation
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/data-table.test.tsx`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/alert.test.tsx packages/ui/src/components/avatar.test.tsx packages/ui/src/components/badge.test.tsx`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/stat-card.test.tsx packages/ui/src/components/metric-card.test.tsx`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/patterns/page-header.test.tsx packages/ui/src/patterns/app-shell.test.tsx packages/ui/src/patterns/page-footer.test.tsx`
- `pnpm --filter @ai-ui/ui exec tsc --noEmit`
- `pnpm --dir apps/docs exec tsc --noEmit`
## Status Log
- `2026-03-25 17:27` started the pass after the previous motion review identified remaining polish
gaps in `DataTable`, foundation surfaces, workspace patterns, and motion-story/test coverage
- `2026-03-25 17:28` wrote the execution plan and dispatched four isolated workers for
`DataTable`, foundation components, workspace patterns, and KPI-card coverage
- `2026-03-25 17:33` reviewed and accepted all four returned slices, then validated the combined
result set with focused Vitest coverage plus `pnpm --filter @ai-ui/ui exec tsc --noEmit` and
`pnpm --dir apps/docs exec tsc --noEmit`
@@ -0,0 +1,59 @@
# Gauge Component
- Status: `completed`
- Owner: `Codex`
- Date: `2026-03-25`
## Goal
Add a true radial `Gauge` component to `packages/ui` so the system supports meter-style KPI
visuals in addition to linear `Progress` and segmented progress bars.
## Scope
- In scope:
- add a new `Gauge` component with radial SVG rendering
- support dial and semi-circle shapes
- cover the component with tests and Storybook docs
- export the component from `@ai-ui/ui`
- Out of scope:
- registry metadata refresh while the worktree already contains unrelated registry edits
- retrofitting local draft dashboard stories to use the new component
- animated needle / pointer gauges
## Constraints
- `Gauge` should use `role="meter"` rather than overloading `Progress`.
- Keep `variant` semantic and reserve geometry differences for a dedicated shape dimension.
- Follow the current Cadence UI tonal and motion language.
- Preserve the existing dirty worktree and avoid rewriting unrelated draft files.
## Affected Surfaces
- `packages/ui/src/components/gauge.tsx`
- `packages/ui/src/components/gauge.variants.ts`
- `packages/ui/src/components/gauge.test.tsx`
- `packages/ui/src/skins.css`
- `packages/ui/src/index.ts`
- `apps/docs/src/components/gauge.stories.tsx`
## Plan
1. Define the Gauge API, rendering geometry, and stable slots.
2. Implement the component with skin tokens and SVG arc math.
3. Add tests and Storybook docs for geometry, states, and accessibility guidance.
4. Run targeted validation for the package surface and changed files.
## Validation
- `pnpm --filter @ai-ui/ui typecheck`
- `pnpm --filter @ai-ui/ui build`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/gauge.test.tsx --config ../../vitest.config.ts`
- `pnpm exec eslint apps/docs/src/components/gauge.stories.tsx packages/ui/src/components/gauge.tsx packages/ui/src/components/gauge.variants.ts packages/ui/src/components/gauge.test.tsx packages/ui/src/index.ts`
## Status Log
- `2026-03-25 18:30` Read the closest dashboard-oriented component and story patterns, and scoped Gauge as a separate meter primitive rather than an extension of Progress.
- `2026-03-25 18:42` Implemented the `Gauge` component with radial SVG geometry, dial and semi-circle shapes, meter semantics, and stable slots.
- `2026-03-25 18:47` Added package exports, skin tokens, unit coverage, and a dedicated Storybook docs page for Gauge.
- `2026-03-25 18:51` Verified `pnpm --filter @ai-ui/ui typecheck`, `pnpm --filter @ai-ui/ui build`, `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/gauge.test.tsx --config ../../vitest.config.ts`, targeted `eslint`, and `pnpm harness:validate:docs`.
@@ -0,0 +1,73 @@
# Grid Primitives
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Add Cadence UI source-owned `Row` / `Col` grid primitives so product and docs surfaces can build
responsive split layouts and dashboard sections without repeating ad hoc width math, alignment
fixes, or one-off `grid-cols-*` strings.
## Scope
- In scope:
- add public `Row` and `Col` primitives to `packages/ui`
- support a 12-column responsive layout model with tokenized gaps
- support base plus breakpoint-specific `span` and `offset` placement on `Col`
- expose stable slot and `data-*` hooks
- document the primitives in Storybook
- add unit coverage for the main layout contract
- Out of scope:
- nested layout orchestration helpers such as `SplitPanel` or dialog-specific shells
- CSS container-query driven layout APIs
- ordering, push/pull, or visual reordering props that can drift from DOM order
- replacing every existing docs layout in this pass
## Constraints
- Keep the API small and familiar to teams coming from Ant Design / Element style `Row` / `Col`.
- Stay token-first for spacing instead of baking new hardcoded per-story gutters.
- Preserve DOM order as the semantic order; layout props should not encourage accessibility drift.
- Avoid introducing an external layout framework or dependency for this capability.
## Affected Surfaces
- `packages/ui/src/components`
- `packages/ui/src/index.ts`
- `packages/ui/src/lib/contracts.ts`
- `packages/ui/src/skins.css`
- `apps/docs/src/components`
- `docs/exec-plans`
- `registry/index.json`
## Plan
1. Add the execution plan and define a minimal Cadence UI grid contract.
2. Implement `Row` and `Col` with a 12-column base, tokenized gap sizes, and responsive
`span` / `offset` placement.
3. Export the primitives on the public surface and document them in Storybook with usage,
anatomy, and accessibility guidance.
4. Add focused unit coverage, run narrow validation, then rebuild registry metadata for the
new public entrypoint.
## Validation
- `pnpm --filter @ai-ui/ui test -- grid`
- `pnpm build:docs`
- `pnpm registry:build`
## Orchestration Task Sketch
- `T1`: implement the grid primitives, skins, and public exports
- `T2 -> T1`: add Storybook docs, tests, and rebuild registry metadata
## Status Log
- `2026-03-25 16:18` started after confirming the repo has many direct grid utility usages but no
reusable source-owned layout primitive for responsive row/column composition
- `2026-03-25 18:54` implemented public `Row` / `Col` primitives, exported them from `@ai-ui/ui`,
added Storybook coverage, and registered the new source-owned `grid` entrypoint
- `2026-03-25 18:56` validated with `pnpm --filter @ai-ui/ui test -- grid`,
`pnpm --filter @ai-ui/ui build`, `pnpm build:docs`, and `pnpm registry:build`
@@ -0,0 +1,74 @@
# Input Group Affixes
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Add a reusable input-affix composition layer so search icons, badges, and suffix actions stop
being page-level absolute-position wrappers and instead ship as a stable Cadence UI pattern.
## Scope
- In scope:
- add a public `InputGroup` companion pattern with prefix and suffix slots
- keep the existing `Input` API intact while letting it compose cleanly inside the new group
- update at least one real docs surface that currently hand-rolls affix layout
- add Storybook coverage and unit tests for the new slot and field wiring behavior
- Out of scope:
- redesigning `Field` or introducing a second label/help-text abstraction
- broad visual restyling of all text-entry surfaces
- migrating every existing search field in one pass
## Constraints
- Preserve the existing `Input` ref and prop contract for non-grouped usage.
- Follow the current `Field` state inheritance rules for disabled, invalid, readonly, and
required behavior.
- Expose stable `data-slot` hooks for the new affix anatomy.
- Keep the new pattern token-driven and visually aligned with the current input chrome.
## Affected Surfaces
- `docs/exec-plans/2026-03-25-input-group-affixes.md`
- `packages/ui/src/lib/contracts.ts`
- `packages/ui/src/components/input.tsx`
- `packages/ui/src/components/input-group.tsx`
- `packages/ui/src/components/input-group.variants.ts`
- `packages/ui/src/components/input-group.test.tsx`
- `packages/ui/src/components/data-table.tsx`
- `packages/ui/src/components/command.tsx`
- `packages/ui/src/components/command.test.tsx`
- `packages/ui/src/index.ts`
- `apps/docs/src/components/input-group.stories.tsx`
- `apps/docs/src/revenue-dashboard.stories.tsx`
- `registry/index.json`
## Plan
1. Add the execution plan and define the new input-affix pattern against the existing
`Input`/`Field` contract.
2. Implement `InputGroup` plus grouped-input behavior without regressing standalone `Input`.
3. Migrate the most obvious repeated search wrapper surfaces to the new composition layer.
4. Add stories, tests, and registry updates, then run the narrowest useful validation passes.
## Validation
- `pnpm --filter @ai-ui/ui test -- --run packages/ui/src/components/input-group.test.tsx packages/ui/src/components/input.test.tsx packages/ui/src/components/command.test.tsx`
- `pnpm --filter @ai-ui/ui typecheck`
- `pnpm harness:validate:docs`
- `pnpm registry:build`
## Orchestration Task Sketch
- `T1`: implement the input group component and integrate standalone input behavior
- `T2 -> T1`: migrate docs usage and command/data-table composition to the new slots
- `T3 -> T1`: add tests, stories, and registry updates
## Status Log
- `2026-03-25 14:29` Read the system-of-record files and confirmed the current `Input` contract has no affix composition layer.
- `2026-03-25 14:35` Chose a sibling `InputGroup` pattern over mutating `Input` into a wrapped root so the existing input API stays stable outside grouped usage.
- `2026-03-25 15:17` Implemented `InputGroup`, grouped input behavior, and the first consumer migrations in `CommandInput`, `DataTableSearch`, and the revenue dashboard story.
- `2026-03-25 15:24` Verified `pnpm --filter @ai-ui/ui test -- --run packages/ui/src/components/input-group.test.tsx packages/ui/src/components/input.test.tsx packages/ui/src/components/command.test.tsx packages/ui/src/components/data-table.test.tsx`, `pnpm --filter @ai-ui/ui typecheck`, `pnpm harness:validate:docs`, and `pnpm registry:build`.
@@ -0,0 +1,68 @@
# Layout Patterns Layer
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Add a dedicated `patterns` layer inside `@ai-ui/ui` for reusable page and shell structures that
sit above base components: `SidebarNav`, `PageHeader`, `AppShell`, and `PageFooter`.
## Scope
- In scope:
- create `packages/ui/src/patterns/`
- implement `SidebarNav`, `PageHeader`, `AppShell`, and `PageFooter`
- export the new patterns from `@ai-ui/ui`
- add Storybook docs under `Patterns/*`
- refactor the revenue dashboard scene to consume the new patterns where they fit
- add focused tests for the public slot and state contract
- Out of scope:
- splitting a second npm package for patterns
- adding router-aware or data-source-aware app shells
- replacing every existing layout composition across the repo in one slice
## Constraints
- Keep `components` as the lower layer and allow `patterns` to depend on `components`, not the reverse.
- Follow `DESIGN.md` and reuse the existing token and motion system instead of inventing a separate layout skin.
- Keep APIs compound and slot-oriented so different pages can restyle the same patterns.
- Use Storybook titles under `Patterns/*` so the review surface reflects the new layer clearly.
## Affected Surfaces
- `packages/ui/src/patterns`
- `packages/ui/src/index.ts`
- `apps/docs/src/patterns`
- `apps/docs/src/revenue-dashboard.stories.tsx`
- `docs/exec-plans`
## Plan
1. Create the new `patterns` directory and implement the four shared layout patterns with stable
slots and restrained variant surfaces.
2. Export the patterns from `@ai-ui/ui` without moving the existing base components.
3. Add docs stories under `Patterns/*` that explain when to use each pattern.
4. Refactor the revenue dashboard scene to consume `SidebarNav`, `PageHeader`, and `AppShell`, and
use `PageFooter` where it adds real value.
5. Add focused tests and run the narrowest useful validation suites.
## Validation
- `pnpm --filter @ai-ui/ui test -- sidebar-nav page-header page-footer app-shell`
- `pnpm --filter @ai-ui/ui typecheck`
- `pnpm build:docs`
## Orchestration Task Sketch
- `T1`: implement the new `patterns` layer and exports
- `T2 -> T1`: add docs stories for the four patterns
- `T3 -> T2`: refactor the revenue dashboard consumer and run focused validation
## Status Log
- `2026-03-25 16:48` started the patterns-layer slice after confirming the repo has component-internal headers and footers but no dedicated page-shell layer for shared layout structures
- `2026-03-25 17:06` implemented the new `packages/ui/src/patterns/` layer with `SidebarNav`, `PageHeader`, `PageFooter`, and `AppShell`, then exported the new public surface from `@ai-ui/ui`
- `2026-03-25 17:10` added Storybook docs under `Patterns/*` and refactored the revenue dashboard scene to consume the new shell and page patterns
- `2026-03-25 17:12` validated exact new pattern tests, workspace typecheck, and Storybook production build; the broader `pnpm --filter @ai-ui/ui test -- ...` entrypoint still pulls in unrelated in-flight `combobox` and `command` failures from concurrent work
@@ -0,0 +1,93 @@
# Motion Accessibility And Selection Pass
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Polish the remaining low-level interaction motion gaps called out in the motion review, with an
emphasis on reduced-motion correctness, selection-state feedback, and bringing lower-tier
components closer to the established `Command` / `Combobox` / `Tabs` motion quality bar.
## Scope
- In scope:
- add explicit reduced-motion handling for continuous loader animation surfaces
- soften `Checkbox` and `RadioGroup` selection feedback with short scale/fade indicator motion
- give `Tooltip` a light rise to match the rest of the overlay family
- reduce layout-heavy motion where it is currently doing too much work in `Accordion`,
`Sparkbar`, and `Progress`
- polish `Select` list motion so it feels closer to `Combobox` and `Command`
- update focused tests and stories when the behavior contract changes
- Out of scope:
- introducing new animation dependencies
- expanding public APIs unless a small compatibility-safe prop is justified
- broad visual restyling unrelated to motion or reduced-motion behavior
## Constraints
- Respect `DESIGN.md` and keep one calm, spatial motion language.
- Treat `packages/ui/src/components/progress*` as main-thread-owned because reduced-motion and
motion-refinement requirements both land there.
- Do not revert or overwrite unrelated in-progress repo changes.
- Preserve static-motion behavior through `data-motion="static"` and make OS-level reduced motion
behave equivalently for continuous animation surfaces.
## Ownership
- Main thread:
- `packages/ui/src/components/progress*`
- final review, integration, and validation
- Worker 1:
- `packages/ui/src/components/spinner.tsx`
- `packages/ui/src/components/skeleton.tsx`
- nearest tests/stories if needed
- Worker 2:
- `packages/ui/src/components/checkbox*`
- `packages/ui/src/components/radio-group*`
- nearest tests/stories if needed
- Worker 3:
- `packages/ui/src/components/tooltip*`
- nearest tests/stories if needed
- Worker 4:
- `packages/ui/src/components/accordion*`
- `packages/ui/src/components/sparkbar*`
- nearest tests/stories if needed
- Worker 5:
- `packages/ui/src/components/select*`
- nearest tests/stories if needed
## Plan
1. Write the execution plan and keep `Progress` under main-thread ownership.
2. Dispatch five isolated workers for loaders, selection controls, tooltip, accordion/sparkbar,
and select.
3. Implement `Progress` reduced-motion and motion refinements locally.
4. Review and integrate the worker slices, then fix any shared token or contract issues.
5. Run focused tests for the changed surfaces and a narrow typecheck/docs pass if needed.
## Validation
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/progress.test.tsx`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/checkbox.test.tsx packages/ui/src/components/radio-group.test.tsx`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/tooltip.test.tsx`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/accordion.test.tsx packages/ui/src/components/sparkbar.test.tsx`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/select.test.tsx`
- `pnpm --filter @ai-ui/ui exec tsc --noEmit`
## Status Log
- `2026-03-25 17:18` started the focused pass after motion review identified reduced-motion gaps,
low-fidelity selection feedback, and one remaining `Select` polish gap
- `2026-03-25 17:19` wrote the execution plan, kept `progress.*` under main-thread ownership, and
dispatched five isolated workers for loaders, selection controls, tooltip, accordion/sparkbar,
and select
- `2026-03-25 17:22` landed the main-thread `Progress` refactor: reduced-motion detection now
collapses indeterminate animation, the linear bar scales with `transform` instead of animating
layout width, and segmented indeterminate progress falls back to a static partial fill when
motion is reduced
- `2026-03-25 17:24` reviewed and accepted worker slices for loaders, selection controls, tooltip,
accordion/sparkbar, and select polish
- `2026-03-25 17:25` validated the combined result set with focused component tests plus
`pnpm --filter @ai-ui/ui exec tsc --noEmit` and `pnpm --dir apps/docs exec tsc --noEmit`
@@ -0,0 +1,89 @@
# Motion Polish Pass
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Run a second polish pass focused on interaction motion across six existing component surfaces. The
goal is to add the missing sense of staged, spatial feedback described in `DESIGN.md` without
introducing a second motion language, widening public APIs unnecessarily, or making the system feel
busy.
## Scope
- In scope:
- add polished active-guide, tooltip, and marker motion to `Chart`
- add a one-time sweep and value count-up feel to `Gauge`
- add shared active-pill motion to `Tabs` and `SegmentedControl`
- add lighter interaction transitions to the newly polished `DataTable`
- add subtle media float/breathe and CTA stagger to `EmptyState`
- add active-row glide, list crossfade, and softer loading treatment to `Command` and `Combobox`
- update the nearest stories and tests when behavior changes need review or coverage
- Out of scope:
- introducing new animation dependencies
- changing shared token files unless the main thread explicitly decides it is necessary
- turning core surfaces into ambient animation showcases
## Constraints
- Respect reduced motion and the repo's current Material-runtime design direction.
- Favor `transform`, `opacity`, `box-shadow`, and state-driven transitions over layout-heavy motion.
- Keep each worker on an isolated write scope.
- Preserve existing public component contracts unless a small compatibility-safe extension is
justified.
- Do not revert or overwrite unrelated in-progress repo changes.
## Affected Surfaces
- `packages/ui/src/components/chart*`
- `packages/ui/src/components/gauge*`
- `packages/ui/src/components/tabs*`
- `packages/ui/src/components/segmented-control*`
- `packages/ui/src/components/data-table*`
- `packages/ui/src/components/empty-state*`
- `packages/ui/src/components/command*`
- `packages/ui/src/components/combobox*`
- `apps/docs/src/components/chart.stories.tsx`
- `apps/docs/src/components/gauge.stories.tsx`
- `apps/docs/src/components/tabs.stories.tsx`
- `apps/docs/src/components/segmented-control.stories.tsx`
- `apps/docs/src/components/data-table.stories.tsx`
- `apps/docs/src/components/empty-state.stories.tsx`
- `apps/docs/src/components/command.stories.tsx`
- `apps/docs/src/components/combobox.stories.tsx`
- `docs/exec-plans`
## Plan
1. Write a dedicated execution plan for the motion pass so the second round of work is resumable.
2. Dispatch six isolated workers, each owning one motion slice and its closest tests/docs.
3. Review the combined result set in the main thread and fix any compatibility or API-semantics
issues before broader validation.
4. Run focused component, typecheck, and docs validation on the combined motion changes.
5. Record results, remaining risks, and any intentionally deferred motion refinements.
## Validation
- `pnpm --filter @ai-ui/ui exec vitest run <owned test files>`
- `pnpm --filter @ai-ui/ui exec tsc --noEmit`
- `pnpm --dir apps/docs exec tsc --noEmit`
- `pnpm build:docs`
## Orchestration Task Sketch
- `T1`: polish `Chart` motion
- `T2`: polish `Gauge` motion
- `T3`: polish `Tabs` and `SegmentedControl` motion
- `T4`: polish `DataTable` motion
- `T5`: polish `EmptyState` motion
- `T6`: polish `Command` and `Combobox` motion
- `T7 -> T1,T2,T3,T4,T5,T6`: integrate results and validate
## Status Log
- `2026-03-25 16:45` started the motion-focused second pass after confirming the current component state from the first visual polish round
- `2026-03-25 16:47` wrote the motion pass plan and prepared six isolated worker slices with main-thread integration reserved for compatibility fixes and validation
- `2026-03-25 16:59` all six motion slices returned from delegated workers: `Chart`, `Gauge`, `Tabs/SegmentedControl`, `DataTable`, `EmptyState`, and `Command/Combobox`
- `2026-03-25 17:00` validated the combined result set with targeted Vitest coverage, `pnpm --filter @ai-ui/ui exec tsc --noEmit`, `pnpm --dir apps/docs exec tsc --noEmit`, and `pnpm build:docs`
@@ -0,0 +1,60 @@
# Progress Patterns
- Status: `completed`
- Owner: `Codex`
- Date: `2026-03-25`
## Goal
Extend `Progress` beyond the default linear fill so the component can also render segmented
dashboard-style progress bars like the operational cost reduction example, without splitting the
API into separate one-off components.
## Scope
- In scope:
- add a structural `pattern` dimension to `Progress`
- support a segmented dashboard meter mode
- keep the existing linear pattern untouched as the default
- update tests, exports, and Storybook docs
- Out of scope:
- radial gauge visuals
- introducing a separate `Gauge` component
- modifying unrelated dashboard story drafts
## Constraints
- Keep `variant` reserved for semantic color intent.
- Preserve the existing ARIA progressbar behavior.
- Expose stable slots and `data-*` hooks for the segmented structure.
- Stay within the current Cadence UI tonal and motion language.
## Affected Surfaces
- `packages/ui/src/components/progress.tsx`
- `packages/ui/src/components/progress.variants.ts`
- `packages/ui/src/components/progress.test.tsx`
- `packages/ui/src/skins.css`
- `packages/ui/src/index.ts`
- `apps/docs/src/components/progress.stories.tsx`
## Plan
1. Add a `pattern` API for `linear` and `segmented` progress structures.
2. Implement segmented rendering with stable slots and deterministic filled-state logic.
3. Refresh docs so the segmented meter is shown in a realistic dashboard context.
4. Run targeted validation for the package and docs surfaces.
## Validation
- `pnpm --filter @ai-ui/ui typecheck`
- `pnpm --filter @ai-ui/ui test -- --run packages/ui/src/components/progress.test.tsx`
- `pnpm harness:validate:docs`
## Status Log
- `2026-03-25 17:58` Confirmed the existing Progress API is linear-only and inspected the local revenue dashboard draft for the target segmented visual language.
- `2026-03-25 18:10` Added a `pattern` dimension with segmented rendering, repeated segment slots, and progress-specific skin tokens for dashboard meters.
- `2026-03-25 18:14` Updated Storybook docs and package exports to document and expose the segmented mode.
- `2026-03-25 18:18` Verified `pnpm --filter @ai-ui/ui typecheck`, `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/progress.test.tsx --config ../../vitest.config.ts`, and targeted `eslint` on the changed files.
- `2026-03-25 18:19` `pnpm harness:validate:docs` is currently blocked by an unrelated local Storybook draft: `apps/docs/src/components/metric-card.stories.tsx` imports `MetricCard` from `@ai-ui/ui`, but that export is not present in the built package yet.
@@ -0,0 +1,57 @@
# Progress Refresh
- Status: `completed`
- Owner: `Codex`
- Date: `2026-03-25`
## Goal
Bring `Progress` closer to the active Cadence UI visual language so it reads like a tonal system
component instead of a generic utility bar, while keeping the existing API and accessibility
contract intact.
## Scope
- In scope:
- refresh the `Progress` track and indicator styling
- add progress-specific skin tokens in `packages/ui/src/skins.css`
- update Storybook stories so the new anatomy, state, and motion behavior are reviewable
- keep unit coverage aligned with the component contract
- Out of scope:
- adding labels, percentages, or new public props
- changing exports or introducing new dependencies
## Constraints
- Preserve the current `Progress` API surface (`size`, `tone`, `variant`, `value`, `max`).
- Keep the stable `data-slot` and `data-state` hooks.
- Use existing token roles and motion primitives instead of hardcoded one-off colors.
- Keep indeterminate behavior usable under static/reduced motion.
## Affected Surfaces
- `packages/ui/src/components/progress.tsx`
- `packages/ui/src/components/progress.variants.ts`
- `packages/ui/src/components/progress.test.tsx`
- `packages/ui/src/skins.css`
- `apps/docs/src/components/progress.stories.tsx`
## Plan
1. Add progress skin tokens for track depth, highlight, and indicator gloss.
2. Rework the component styling around those tokens without changing the public props.
3. Refresh docs stories to show the component in more intentional, reviewable product contexts.
4. Run the narrowest harness suites that cover the changed component and docs surfaces.
## Validation
- `pnpm harness:validate:component`
- `pnpm harness:validate:docs`
## Status Log
- `2026-03-25 17:20` Read system-of-record files and inspected the current Progress source, tests, and stories.
- `2026-03-25 17:25` Started implementation for the visual refresh and docs alignment.
- `2026-03-25 17:42` Added progress skin tokens, refreshed the track and indicator styling, and rewrote the Storybook review surface.
- `2026-03-25 17:48` Verified `pnpm --filter @ai-ui/ui typecheck`, `pnpm --filter @ai-ui/ui test -- --run packages/ui/src/components/progress.test.tsx`, and `pnpm harness:validate:docs`.
- `2026-03-25 17:49` `pnpm harness:validate:component` still fails on pre-existing type errors in `apps/docs/src/revenue-dashboard.stories.tsx`, unrelated to the Progress work.
@@ -0,0 +1,72 @@
# Segmented Control
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Add a compact `SegmentedControl` component to `@ai-ui/ui` for lightweight dashboard-style view
switching so consumers do not need to stretch `Tabs` into every small top-of-panel toggle.
## Scope
- In scope:
- add a new `SegmentedControl` component to `packages/ui`
- expose the component from the package entrypoint
- add unit coverage for controlled/uncontrolled behavior and stable contract hooks
- add Storybook docs that explain when to choose `SegmentedControl` instead of `Tabs`
- update the revenue dashboard story to use the new control for its contribution switcher
- refresh `registry/index.json` so source-copy consumers can install the new component
- Out of scope:
- redesigning the existing `Tabs` API
- replacing every current `Tabs` usage across docs
- adding icon-only, multi-select, or overflow handling variants in the first pass
## Constraints
- Keep the API narrower than `Tabs`; this should solve lightweight switching, not become another
generic navigation primitive.
- Reuse the current token and motion language from `DESIGN.md` instead of introducing a new visual
style.
- Preserve stable `data-slot` and `data-*` hooks for docs and tests.
- Do not add a new dependency if the existing Radix stack is sufficient.
## Affected Surfaces
- `docs/exec-plans/2026-03-25-segmented-control.md`
- `packages/ui/src/components/segmented-control.tsx`
- `packages/ui/src/components/segmented-control.variants.ts`
- `packages/ui/src/components/segmented-control.test.tsx`
- `packages/ui/src/index.ts`
- `apps/docs/src/components/segmented-control.stories.tsx`
- `apps/docs/src/revenue-dashboard.stories.tsx`
- `registry/index.json`
## Plan
1. Add the execution plan and confirm the public contract for a compact single-select control.
2. Implement `SegmentedControl` in `packages/ui` with stable slots, disabled state, and controlled
or uncontrolled value support.
3. Add Storybook docs that explain the intended usage boundary against `Tabs`.
4. Replace the revenue dashboard contribution switcher with `SegmentedControl` to prove the
dashboard-friendly use case.
5. Run targeted validation and refresh registry metadata.
## Validation
- `pnpm test -- --runInBand segmented-control tabs`
- `pnpm harness:validate:docs`
- `pnpm registry:build`
## Orchestration Task Sketch
- `T1`: implement `SegmentedControl` source, variants, and exports
- `T2 -> T1`: add docs stories and migrate the revenue dashboard example
- `T3 -> T1`: add tests and refresh registry metadata
## Status Log
- `2026-03-25 20:07` Read the system-of-record files and confirmed that `Tabs` is currently the only compact peer-switching primitive in the public surface.
- `2026-03-25 20:18` Started the segmented-control slice to cover lightweight dashboard toggles without expanding the `Tabs` API.
- `2026-03-25 23:32` Shipped the new `SegmentedControl`, migrated the revenue dashboard contribution switcher, refreshed `registry/index.json`, and cleared the existing `InputGroup` type blocker so build and docs validation could pass again.
@@ -0,0 +1,72 @@
# Sparkbar Primitive
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Add a dedicated `Sparkbar` micro-visual primitive to `@ai-ui/ui` so compact KPI cards and
dashboard tiles can render tiny bar-based trend summaries without hand-written DOM.
## Scope
- In scope:
- add a new `Sparkbar` component to `packages/ui`
- keep the API narrow and card-focused: small size, low configuration, embedded usage
- support simple bar highlighting for active ranges without turning the component into a full
charting surface
- add unit tests and Storybook docs for the public contract
- replace the hand-written sparkbar markup in `apps/docs/src/revenue-dashboard.stories.tsx`
and the related metric-card docs story
- Out of scope:
- introducing a full `Sparkline` API in the same slice
- adding axes, tooltips, interaction, or data transforms that belong to `Chart`
- expanding `Chart` itself to absorb micro-visual responsibilities
- broader dashboard contract cleanup outside the bar micro-vis use cases
## Constraints
- Follow `DESIGN.md` and keep the visual language tonal, rounded, and quiet enough for card media.
- Preserve a stable slot and `data-*` contract so docs and tests can target the primitive.
- Keep the public API additive and intentionally smaller than `Chart`.
- Avoid rewriting unrelated dirty-worktree changes while touching shared files like docs stories and
package exports.
## Affected Surfaces
- `docs/exec-plans/2026-03-25-sparkbar-primitive.md`
- `packages/ui/src/components/sparkbar.tsx`
- `packages/ui/src/components/sparkbar.variants.ts`
- `packages/ui/src/components/sparkbar.test.tsx`
- `packages/ui/src/index.ts`
- `packages/ui/src/skins.css`
- `apps/docs/src/components/sparkbar.stories.tsx`
- `apps/docs/src/components/metric-card.stories.tsx`
- `apps/docs/src/revenue-dashboard.stories.tsx`
- `registry/index.json`
## Plan
1. Define a narrow `Sparkbar` API for embedded numeric bar trends and active-range emphasis.
2. Implement the component with stable slots, tone/variant styling, and decorative-by-default
accessibility behavior.
3. Add Storybook stories that explain when to use `Sparkbar` instead of `Chart`.
4. Replace the existing hand-written bar maps in docs consumers with the new primitive.
5. Run focused package and docs validation, then record any remaining gaps.
## Validation
- `pnpm --filter @ai-ui/ui typecheck`
- `pnpm --filter @ai-ui/ui exec vitest run packages/ui/src/components/sparkbar.test.tsx --config ../../vitest.config.ts`
- `pnpm exec eslint apps/docs/src/components/sparkbar.stories.tsx apps/docs/src/components/metric-card.stories.tsx apps/docs/src/revenue-dashboard.stories.tsx packages/ui/src/components/sparkbar.tsx packages/ui/src/components/sparkbar.variants.ts packages/ui/src/components/sparkbar.test.tsx packages/ui/src/index.ts`
- `pnpm harness:validate:docs`
- `pnpm registry:build`
## Status Log
- `2026-03-25 21:15` Read the system-of-record files and confirmed the repo currently has a full `Chart` pattern but no dedicated micro bar-trend primitive.
- `2026-03-25 21:22` Scoped the new component as `Sparkbar`: low-config, non-interactive, card-embedded, and explicitly separate from `Chart`.
- `2026-03-25 23:18` Implemented `Sparkbar`, tokenized its size/tone/variant styling, exported it from `@ai-ui/ui`, and added unit coverage for slots, emphasis, and accessibility behavior.
- `2026-03-25 23:28` Added dedicated Storybook docs and migrated the handwritten spark bars in the revenue dashboard and metric-card stories to the new primitive.
- `2026-03-25 23:34` Verified `pnpm --filter @ai-ui/ui typecheck`, targeted Sparkbar Vitest coverage, targeted ESLint, `pnpm harness:validate:docs`, and `pnpm registry:build`.
@@ -0,0 +1,65 @@
# Stat And Metric Cards
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Add two dashboard-oriented composition components to `@ai-ui/ui`: a compact `StatCard`
for the common label/value/delta/description KPI pattern, and a richer `MetricCard`
that extends the same pattern with media, footer, and action regions for analytics
panels.
## Scope
- In scope:
- add `StatCard` and `MetricCard` component source, variants, exports, tests, and docs
- expose stable slot and state metadata consistent with the repo contract
- refactor the new revenue dashboard scene to consume the components where they fit
- Out of scope:
- introducing a full dashboard layout system
- replacing all existing card-based compositions across the repo
- expanding the chart system beyond what already exists
## Constraints
- Follow the active Material-inspired design direction in `DESIGN.md`.
- Keep the API small and slot-oriented rather than introducing a single oversized props surface.
- Reuse existing card tokens and motion recipes instead of creating a parallel styling layer.
- Preserve enough flexibility that richer panels can still compose custom chart and footer content.
## Affected Surfaces
- `packages/ui/src/components`
- `packages/ui/src/index.ts`
- `apps/docs/src/components`
- `apps/docs/src/revenue-dashboard.stories.tsx`
- `docs/exec-plans`
## Plan
1. Implement `StatCard` as a compact KPI card with semantic slots for eyebrow, label, value, delta, and description.
2. Implement `MetricCard` on the same visual and slot contract, adding media, footer, and actions regions plus layout variants.
3. Add focused tests and Storybook stories that document the standard usage, anatomy, and review guidance.
4. Refactor the revenue dashboard scene to consume the new components for representative panels.
5. Run focused validation and record any unrelated repository issues that remain.
## Validation
- `pnpm --filter @ai-ui/ui typecheck`
- `pnpm --filter @ai-ui/ui test`
- `pnpm build:docs`
## Orchestration Task Sketch
- `T1`: implement `StatCard` and `MetricCard` source plus exports
- `T2 -> T1`: add stories/tests and refactor the revenue dashboard scene
- `T3 -> T2`: run focused validation and document residual issues
## Status Log
- `2026-03-25 16:16` started the stat/metric card slice after confirming the current dashboard scene still relies on repeated KPI glue code
- `2026-03-25 16:27` implemented `StatCard` and `MetricCard`, exported them from `@ai-ui/ui`, added tests and Storybook stories, and refactored representative revenue-dashboard panels onto the new components
- `2026-03-25 16:31` validated `pnpm --filter @ai-ui/ui typecheck`, `pnpm --filter @ai-ui/ui test`, `pnpm build`, and `pnpm build:docs`
- `2026-03-25 16:33` noted that the running Storybook dev session still carries unrelated development noise from other in-flight docs slices (`progress.stories.tsx` index warnings and an `InputGroup` runtime export mismatch on the dashboard iframe), but the package build and Storybook production build both pass
@@ -0,0 +1,50 @@
# Storybook Importer Cache
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Prevent Storybook from serving a stale Vite importer map after story files are added or removed,
which currently causes runtime failures like `importers[path] is not a function` even though the
story index already references the new file.
## Scope
- In scope:
- diagnose the mismatch between `index.json` and the virtual `storybook-stories.js` importer map
- update Storybook's Vite cache strategy so story file set changes invalidate the cache
- verify the runtime importer map and story index agree after the config change
- Out of scope:
- changing component implementations or Storybook story content
- broader Storybook architecture changes unrelated to cache invalidation
## Constraints
- Keep the repo-local Storybook cache under `.artifacts/`.
- Prefer invalidating on story file set changes, not on every edit, so normal dev caching remains useful.
- Avoid introducing new package dependencies just to compute a cache key.
## Affected Surfaces
- `docs/exec-plans/2026-03-25-storybook-importer-cache.md`
- `apps/docs/.storybook/main.ts`
## Plan
1. Record the importer/index mismatch and confirm it is cache-related.
2. Derive the Storybook Vite cache directory from the current story file set.
3. Restart Storybook and verify the virtual importer map matches `index.json`.
## Validation
- `pnpm dev:docs`
- compare `http://127.0.0.1:<port>/index.json` against `http://127.0.0.1:<port>/@id/__x00__virtual:/@storybook/builder-vite/storybook-stories.js`
## Status Log
- `2026-03-25 15:31` Confirmed a runtime mismatch: `index.json` references `./src/components/gauge.stories.tsx`, but the cached virtual `storybook-stories.js` importer map does not include it.
- `2026-03-25 15:38` Updated Storybook's Vite cache directory to include a hash of the current story file set so added or removed story files invalidate the cache instead of reusing a stale importer map.
- `2026-03-25 15:42` Restarted Storybook and verified `index.json` and the virtual importer map both report 47 story import paths, including `gauge.stories.tsx` and `input-group.stories.tsx`.
- `2026-03-25 15:47` Verified `pnpm harness:validate:docs` and `pnpm --dir apps/docs run typecheck`; also fixed the local `revenue-dashboard` navigation item typing so docs typecheck returns cleanly.
@@ -0,0 +1,59 @@
# Two-Factor Setup Pattern
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Add a new Storybook pattern for a two-factor setup flow that captures the structure of the
reference UI while staying inside Cadence UI's lighter Material-tonal design direction and
reusing the existing component set instead of introducing a new public package component.
## Scope
- In scope:
- add a new docs pattern under `apps/docs/src/patterns/`
- compose the scene with existing components such as `Badge`, `Button`, `Card`, `Field`, `Input`,
and `InputGroup`
- document why this belongs in `Patterns` instead of `Components`
- Out of scope:
- adding a new public `packages/ui` export
- introducing a dedicated OTP primitive or authentication package
- wiring real QR generation, API calls, or clipboard guarantees
## Constraints
- Keep the visual direction aligned with `DESIGN.md`: tonal, light, rounded, and calm.
- Avoid introducing a black, neon, or high-contrast security-console aesthetic.
- Reuse existing components wherever practical and keep one-off styling local to the story.
- Do not disturb unrelated in-flight work in the dirty worktree.
## Affected Surfaces
- `apps/docs/src/patterns`
- `docs/exec-plans`
## Plan
1. Add the execution plan and confirm this slice should live in `Patterns`.
2. Implement a new `Patterns/TwoFactorSetup` story that composes existing UI primitives into a
modal-ready 2FA setup scene.
3. Add anatomy and accessibility-oriented review stories so placement and usage are explicit.
4. Run focused docs validation and record the result.
## Validation
- `pnpm build:docs`
## Orchestration Task Sketch
- `T1`: add the docs-only two-factor setup pattern story
- `T2 -> T1`: run focused docs validation and record outcomes
## Status Log
- `2026-03-25 22:10` started the slice after confirming the reference is better treated as a composed auth pattern than as a new base component or dialog variant
- `2026-03-25 22:18` added `Patterns/TwoFactorSetup` as a docs-only pattern story built from existing components with a lighter tonal treatment instead of the reference's dark security-console styling
- `2026-03-25 22:20` validated the new story with `pnpm build:docs`
- `2026-03-25 19:02` migrated the scene's split layout and accessibility review layout onto the shared `Row` / `Col` grid primitives so the pattern no longer depends on local fixed desktop column math
@@ -0,0 +1,66 @@
# Value Field Component
- Status: `in_progress`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Add a reusable `ValueField` base component for single-line, read-only value presentation so product
flows do not have to misuse `Input` for non-editable display values such as backup codes, reference
ids, tokens, or generated labels.
## Scope
- In scope:
- add a new public `ValueField` component to `packages/ui`
- support a minimal composable surface for value display plus optional prefix/suffix content
- integrate with `Field` context for state and description wiring
- document the component in Storybook
- cover key behavior with unit tests
- use the new component in the docs-only two-factor setup pattern
- Out of scope:
- multiline or rich-text display values
- formatted masking, async copy state, or built-in clipboard APIs
- replacing every read-only input in the repo
## Constraints
- Keep the component token-first and aligned with the existing input/card/panel visual language.
- Do not create a second form-field system parallel to `Field`.
- Keep the public API small and composable.
- Prefer stable slot names and `data-*` state exposure over one-off booleans.
## Affected Surfaces
- `packages/ui/src/components`
- `packages/ui/src/index.ts`
- `packages/ui/src/lib/contracts.ts`
- `apps/docs/src/components`
- `apps/docs/src/patterns`
- `docs/exec-plans`
## Plan
1. Add the execution plan and confirm the API should be a small read-only display primitive rather
than a typography helper.
2. Implement `ValueField` in `packages/ui` with stable slots for root/value/prefix/suffix and
Field-context aware ids and state attributes.
3. Add Storybook stories and update the two-factor setup pattern to use `ValueField` for the manual
backup code.
4. Add unit coverage and run targeted validation for component and docs surfaces.
## Validation
- `pnpm build:docs`
- targeted component test run for `ValueField`
## Orchestration Task Sketch
- `T1`: implement the `ValueField` component and exports
- `T2 -> T1`: add docs stories, update the two-factor pattern, and validate
## Status Log
- `2026-03-25 23:40` started after confirming the repo exposes read-only input styling but no
dedicated single-line value display component
@@ -0,0 +1,65 @@
# Workspace Toolbar Pattern
- Status: `completed`
- Owner: `codex`
- Date: `2026-03-25`
## Goal
Add a reusable `WorkspaceToolbar` pattern above the base component layer so workspace and desk-style
screens can share one stable contract for search, filters, status chips, and action groups instead
of hand-rolling a new top control row in each story or scene.
## Scope
- In scope:
- add a new `WorkspaceToolbar` pattern under `packages/ui/src/patterns/`
- export the new pattern from `@ai-ui/ui`
- add Storybook docs under `Patterns/WorkspaceToolbar`
- refactor the revenue dashboard scene to consume the new pattern
- add focused tests for the slot and variant contract
- Out of scope:
- moving `DataTable` internal toolbar logic into the patterns layer
- adding router-aware behavior, sticky behavior, or data-source behavior
- introducing a second page-header-like title system inside the toolbar
## Constraints
- Keep the API slot-oriented and composable instead of baking in search or filter behavior.
- Reuse the existing token and motion system from `DESIGN.md`.
- Keep the public variant surface restrained.
- Do not disturb unrelated in-flight work in the dirty worktree.
## Affected Surfaces
- `packages/ui/src/patterns`
- `packages/ui/src/index.ts`
- `apps/docs/src/patterns`
- `apps/docs/src/revenue-dashboard.stories.tsx`
- `docs/exec-plans`
## Plan
1. Add the new pattern implementation, variants, and focused tests in `packages/ui/src/patterns/`.
2. Export the pattern from `@ai-ui/ui` and add Storybook docs under `Patterns/*`.
3. Refactor the revenue dashboard toolbar composition to consume the shared pattern.
4. Run focused validation and record any skipped or blocked checks.
## Validation
- `pnpm --filter @ai-ui/ui test -- workspace-toolbar`
- `pnpm --filter @ai-ui/ui typecheck`
- `pnpm build:docs`
## Orchestration Task Sketch
- `T1`: implement the new pattern and public exports
- `T2 -> T1`: add docs stories and refactor the dashboard consumer
- `T3 -> T2`: run focused validation and record outcomes
## Status Log
- `2026-03-25 19:13` started the workspace-toolbar slice after confirming the current patterns layer stops at shell and page framing, while the revenue dashboard still hand-rolls a workspace control row
- `2026-03-25 19:20` implemented `WorkspaceToolbar` with slot-based search, filters, status, and actions regions, then exported the new public pattern from `@ai-ui/ui`
- `2026-03-25 19:24` added `Patterns/WorkspaceToolbar` Storybook docs and refactored the revenue dashboard scene to consume the shared toolbar pattern
- `2026-03-25 19:26` validated the change with `pnpm --filter @ai-ui/ui test -- workspace-toolbar`, `pnpm --filter @ai-ui/ui typecheck`, and `pnpm build:docs`
+3
View File
@@ -40,6 +40,9 @@ Describe the outcome in one short paragraph.
## Orchestration Task Sketch
- Optional task IDs and dependencies when the work should be dispatched through `pnpm harness:orch`
- Suggested format:
- `T1`: isolated first slice
- `T2 -> T1`: follow-up slice that depends on `T1`
## Status Log
+43 -3
View File
@@ -63,6 +63,8 @@ Primary suites:
- lint, typecheck, and unit coverage for normal component work
- `pnpm harness:validate:docs`
- Storybook build for docs-surface changes
- `pnpm harness:validate:a11y`
- curated Storybook accessibility validation from the shared browser coverage contract
- `pnpm harness:validate:docs-smoke`
- Playwright smoke coverage for high-value Storybook flows
- `pnpm harness:validate:consumers`
@@ -76,6 +78,9 @@ Primary suites:
Each run writes a JSON report to `.artifacts/harness/<suite>.json`.
GitHub Actions uploads the generated harness, a11y, and browser test artifacts from `.artifacts/`
so CI failures stay debuggable after the run finishes.
## Working Loop
Recommended change loop:
@@ -98,6 +103,10 @@ Harness selection is exposed through `pnpm harness:select` and `pnpm harness:val
- `pnpm harness:validate:changed`
- runs the selected suites with a JSON report
The CI workflow uses `pnpm harness:validate:changed` as its execution entrypoint. Selection is no
longer advisory-only: harness-risk and release-risk surfaces escalate directly to the broader `pr`
or `release` gates instead of falling back to `static`.
Selection intentionally maps repo surfaces to validation surfaces:
- package source changes select `component`, `docs`, `docs-smoke`, and `consumers`
@@ -105,6 +114,25 @@ Selection intentionally maps repo surfaces to validation surfaces:
- registry/consumer changes select `static` and `consumers`
- doc-only or metadata-only changes may select no suites
High-risk control-plane changes override those narrow mappings:
- `scripts/harness/*` and other harness-control files escalate to `pr`
- release workflows, release docs, registry docs, and consumer/release scripts escalate to `release`
## Browser Coverage Contract
Curated browser coverage lives in `tests/e2e/support/story-harness-contract.json`.
That contract is the shared source of truth for:
- `pnpm harness:validate:a11y`
- `pnpm harness:validate:docs-smoke`
Each entry records the story id, why it is covered, which suites own it, and any required
interaction scenario keys. When a new high-value review surface should participate in browser or
accessibility validation, update this contract instead of adding one-off story lists in multiple
scripts.
## Worktree Orchestration
Cadence UI also exposes an orchestration wrapper:
@@ -114,23 +142,35 @@ Cadence UI also exposes an orchestration wrapper:
The wrapper keeps orchestration state under `.artifacts/orch/` and applies worktree defaults for
dispatch. Details live in [docs/orchestration.md](/Users/xd/project/cadence-ui/docs/orchestration.md).
Use `pnpm harness:orch -- doctor` to verify binary discovery and plan parsing on a new machine, and
prefer `dispatch --plan-file <plan>` so execution plans remain the source of truth for generated
task bodies.
## Current Rollout Scope
This repository is adding harness engineering in phases.
Phase 1 establishes:
Phase 1 established:
- a documented harness workflow
- execution-plan conventions
- shared validation suites
- a pull request workflow that runs the PR suite
- a pull request workflow that runs the repository harness gate
Phase 2 adds:
Phase 2 established:
- change-aware suite selection from git diff
- a stabilized Storybook smoke harness
- worktree-oriented orchestration defaults
Phase 3 hardens:
- authoritative changed-suite execution in CI
- uploaded CI artifacts for harness, a11y, and browser test output
- a shared browser coverage contract
- orchestration preflight and plan-linked dispatch helpers
- harness self-tests for selection, artifact, and orchestration helpers
Future phases can layer on:
- richer browser/app harnesses for interactive review
+39 -5
View File
@@ -12,6 +12,14 @@ Use the repository wrapper instead of calling `orch` directly:
pnpm harness:orch -- <orch command> [flags]
```
The wrapper also exposes repo-local helper commands:
```bash
pnpm harness:orch -- doctor --plan-file docs/exec-plans/my-plan.md
pnpm harness:orch -- plan inspect --plan-file docs/exec-plans/my-plan.md
pnpm harness:orch -- plan body --run cadence_ui_demo --task T1 --plan-file docs/exec-plans/my-plan.md
```
The wrapper applies these defaults:
- orchestration database: `.artifacts/orch/coord.db`
@@ -20,13 +28,24 @@ The wrapper applies these defaults:
- strict worktree mode for dispatch
- `--base-ref` defaults to the current branch if not provided
Binary resolution order:
1. `CADENCE_UI_ORCH_BIN`
2. `orch` on `PATH`
3. the legacy `~/.codex/skills/orch/assets/orch` location
If no binary is found, `pnpm harness:orch -- doctor` prints the checked locations and exits
non-zero so the failure is explicit instead of looking like a broken wrapper.
## Suggested Workflow
1. Write or update an execution plan in `docs/exec-plans/`.
2. Create an orchestration run.
3. Add tasks and dependencies that map to the execution plan.
4. Dispatch ready tasks into isolated worktrees.
5. Reconcile worker state and run the relevant harness suites before merge.
2. Inspect the plan task sketch with `pnpm harness:orch -- plan inspect --plan-file <plan>`.
3. Create an orchestration run.
4. Add tasks and dependencies that map to the execution plan.
5. Dispatch ready tasks into isolated worktrees, ideally with `--plan-file` so the wrapper can
generate the task body from the plan automatically.
6. Reconcile worker state and run the relevant harness suites before merge.
## Example
@@ -67,9 +86,13 @@ pnpm harness:orch -- dispatch \
--run cadence_ui_harness_001 \
--task T1 \
--to default-worker \
--body-file docs/exec-plans/task-t1.md
--plan-file docs/exec-plans/2026-03-24-harness-control-plane-hardening.md
```
When `--plan-file` is provided without `--body-file`, the wrapper parses the plan's
`## Orchestration Task Sketch`, finds the matching task id, and writes a generated task body under
`.artifacts/orch/task-bodies/<run>/<task>.md` before dispatching.
Inspect status:
```bash
@@ -89,9 +112,20 @@ the plan:
- task bodies should point back to the execution plan and the relevant harness suites
- workers should validate the narrowest useful suites first, then report what remains
Suggested task sketch format:
```md
- `T1`: stabilize docs smoke coverage
- `T2 -> T1`: reconcile a11y coverage and docs after the smoke slice lands
```
`T2 -> T1` means task `T2` depends on `T1`.
## Safety Notes
- dispatch from a committed or otherwise reviewable base when possible
- keep shared integration files on the leader side when multiple workers are active
- prefer one task per isolated write scope
- use `status`, `blocked`, `answer`, and `reconcile` instead of ad hoc coordination
- run `pnpm harness:orch -- doctor` before the first dispatch on a new machine so binary discovery,
defaults, and plan parsing all fail fast
+3
View File
@@ -338,6 +338,9 @@ export const AccordionContent = forwardRef<HTMLDivElement, AccordionContentProps
>
<div
{...props}
{...createDataAttributes({
state: item.open ? "open" : "closed"
})}
className={cn(accordionContentInnerVariants(), className)}
ref={ref}
style={{
@@ -39,11 +39,18 @@ export const accordionIconVariants = cva(
export const accordionContentVariants = cva(
[
"grid overflow-hidden border-t border-[color-mix(in_oklch,var(--ui-card-default-border)_88%,transparent)]",
"transition-[grid-template-rows,opacity] duration-[var(--dur-base)] ease-[var(--ease-emphasized)]",
"data-[state=closed]:grid-rows-[0fr] data-[state=closed]:opacity-70",
"transition-[grid-template-rows,opacity,border-color] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"data-[state=closed]:grid-rows-[0fr] data-[state=closed]:opacity-72",
"data-[state=open]:grid-rows-[1fr] data-[state=open]:opacity-100",
"motion-reduce:transition-none",
getMotionRecipeClassNames("transition")
]
);
export const accordionContentInnerVariants = cva("min-h-0 px-5 pb-5 pt-1");
export const accordionContentInnerVariants = cva([
"min-h-0 px-5 pb-5 pt-1",
"transition-[transform,opacity,filter] duration-[var(--dur-base)] ease-[var(--ease-emphasized)] will-change-transform",
"data-[state=closed]:-translate-y-[calc(var(--distance-xs)*0.75)] data-[state=closed]:scale-[0.985] data-[state=closed]:opacity-0",
"data-[state=open]:translate-y-0 data-[state=open]:scale-100 data-[state=open]:opacity-100",
"motion-reduce:transition-none motion-reduce:data-[state=closed]:translate-y-0 motion-reduce:data-[state=closed]:scale-100"
]);
+11
View File
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Alert, AlertDescription, AlertTitle } from "./alert";
import { alertIconVariants, alertVariants } from "./alert.variants";
describe("Alert", () => {
it("renders root, icon, title, and description slots", () => {
@@ -42,4 +43,14 @@ describe("Alert", () => {
expect(screen.getByRole("status")).toHaveAttribute("data-variant", "default");
});
it("includes restrained entry and icon polish hooks", () => {
expect(alertVariants({ hasIcon: true, variant: "success" })).toContain(
"motion-safe:[animation:aiui-fade-in_var(--dur-base)_var(--ease-standard)_both,aiui-slide-up-sm_var(--dur-fast)_var(--ease-standard)_both]"
);
expect(alertVariants({ hasIcon: true, variant: "success" })).toContain(
"[&>[data-slot=icon]]:bg-[color-mix(in_oklch,var(--color-success)_16%,white_84%)]"
);
expect(alertIconVariants()).toContain("size-8");
});
});
+14 -6
View File
@@ -3,21 +3,24 @@ import { getMotionRecipeClassNames } from "../lib/motion";
export const alertVariants = cva(
[
"relative grid gap-x-3 gap-y-1 rounded-[var(--ui-card-radius)] border p-4 shadow-[var(--ui-card-subtle-shadow)] [border-width:var(--ui-card-border-width)]",
"relative isolate grid gap-x-3 gap-y-1 overflow-hidden rounded-[var(--ui-card-radius)] border p-4 shadow-[var(--ui-card-subtle-shadow)] [border-width:var(--ui-card-border-width)]",
"text-[var(--color-foreground)]",
"before:pointer-events-none before:absolute before:inset-x-[14%] before:top-0 before:h-[58%] before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_24%,transparent),transparent_74%)] before:opacity-70 before:blur-2xl before:content-['']",
"[&>*]:relative [&>*]:z-[1]",
"motion-safe:[animation:aiui-fade-in_var(--dur-base)_var(--ease-standard)_both,aiui-slide-up-sm_var(--dur-fast)_var(--ease-standard)_both] motion-reduce:animate-none",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
variant: {
default:
"border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)]",
"border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)] [&>[data-slot=icon]]:border-[color-mix(in_oklch,var(--color-border)_68%,transparent)] [&>[data-slot=icon]]:bg-[color-mix(in_oklch,var(--color-surface-bright)_82%,white_18%)] [&>[data-slot=icon]]:text-[var(--color-primary)]",
success:
"border-[color-mix(in_oklch,var(--color-success)_34%,var(--ui-card-default-border))] bg-[color-mix(in_oklch,var(--color-success)_10%,var(--ui-card-default-bg))]",
"border-[color-mix(in_oklch,var(--color-success)_34%,var(--ui-card-default-border))] bg-[color-mix(in_oklch,var(--color-success)_10%,var(--ui-card-default-bg))] [&>[data-slot=icon]]:border-[color-mix(in_oklch,var(--color-success)_18%,transparent)] [&>[data-slot=icon]]:bg-[color-mix(in_oklch,var(--color-success)_16%,white_84%)] [&>[data-slot=icon]]:text-[color-mix(in_oklch,var(--color-success)_82%,var(--color-foreground))]",
warning:
"border-[color-mix(in_oklch,var(--color-warning)_34%,var(--ui-card-default-border))] bg-[color-mix(in_oklch,var(--color-warning)_12%,var(--ui-card-default-bg))]",
"border-[color-mix(in_oklch,var(--color-warning)_34%,var(--ui-card-default-border))] bg-[color-mix(in_oklch,var(--color-warning)_12%,var(--ui-card-default-bg))] [&>[data-slot=icon]]:border-[color-mix(in_oklch,var(--color-warning)_18%,transparent)] [&>[data-slot=icon]]:bg-[color-mix(in_oklch,var(--color-warning)_18%,white_82%)] [&>[data-slot=icon]]:text-[color-mix(in_oklch,var(--color-warning)_84%,var(--color-foreground))]",
destructive:
"border-[color-mix(in_oklch,var(--color-destructive)_38%,var(--ui-card-default-border))] bg-[color-mix(in_oklch,var(--color-destructive)_10%,var(--ui-card-default-bg))]"
"border-[color-mix(in_oklch,var(--color-destructive)_38%,var(--ui-card-default-border))] bg-[color-mix(in_oklch,var(--color-destructive)_10%,var(--ui-card-default-bg))] [&>[data-slot=icon]]:border-[color-mix(in_oklch,var(--color-destructive)_20%,transparent)] [&>[data-slot=icon]]:bg-[color-mix(in_oklch,var(--color-destructive)_14%,white_86%)] [&>[data-slot=icon]]:text-[var(--color-destructive)]"
},
hasIcon: {
false: "",
@@ -32,7 +35,12 @@ export const alertVariants = cva(
);
export const alertIconVariants = cva(
"row-span-2 mt-0.5 inline-flex size-5 items-center justify-center rounded-[var(--radius-full)] text-[var(--color-muted-foreground)]"
[
"row-span-2 mt-0.5 inline-flex size-8 items-center justify-center rounded-[var(--radius-full)] border",
"shadow-[inset_0_1px_0_rgba(255,255,255,0.42)]",
"transition-[transform,background-color,color,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"motion-reduce:transition-none"
]
);
export const alertTitleVariants = cva(
@@ -2,6 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Avatar, AvatarFallback } from "./avatar";
import { avatarFallbackVariants, avatarImageVariants, avatarVariants } from "./avatar.variants";
describe("Avatar", () => {
it("renders root slot metadata and fallback content", async () => {
@@ -33,4 +34,12 @@ describe("Avatar", () => {
expect(screen.getByText("JD")).toHaveAttribute("data-slot", "fallback");
});
});
it("exposes image/fallback crossfade hooks", () => {
expect(avatarVariants()).toContain(
"[&>[data-slot=image][data-state=loaded]~[data-slot=fallback]]:opacity-0"
);
expect(avatarImageVariants()).toContain("data-[state=loaded]:opacity-100");
expect(avatarFallbackVariants()).toContain("transition-[opacity,transform,background-color]");
});
});
+11 -3
View File
@@ -3,8 +3,11 @@ import { getMotionRecipeClassNames } from "../lib/motion";
export const avatarVariants = cva(
[
"relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden border shadow-[var(--ui-control-shadow)]",
"relative isolate inline-flex shrink-0 select-none items-center justify-center overflow-hidden border shadow-[var(--ui-control-shadow)]",
"bg-[var(--ui-control-bg)] text-[var(--color-foreground)] [border-width:var(--ui-input-border-width)]",
"before:pointer-events-none before:absolute before:inset-[14%] before:rounded-[inherit] before:bg-[radial-gradient(circle_at_top,color-mix(in_oklch,white_68%,transparent),transparent_74%)] before:opacity-82 before:content-['']",
"[&>*]:relative [&>*]:z-[1]",
"[&>[data-slot=image][data-state=loaded]~[data-slot=fallback]]:opacity-0 [&>[data-slot=image][data-state=loaded]~[data-slot=fallback]]:scale-[0.96]",
getMotionRecipeClassNames("transition", "ring")
],
{
@@ -35,9 +38,14 @@ export const avatarVariants = cva(
);
export const avatarImageVariants = cva([
"size-full object-cover object-center"
"absolute inset-0 size-full object-cover object-center",
"opacity-0 scale-[1.035] transition-[opacity,transform,filter] duration-[var(--dur-base)] ease-[var(--ease-emphasized)]",
"data-[state=loaded]:opacity-100 data-[state=loaded]:scale-100",
"motion-reduce:transition-none motion-reduce:data-[state=loaded]:scale-100"
]);
export const avatarFallbackVariants = cva([
"flex size-full items-center justify-center bg-[inherit] text-inherit font-medium uppercase tracking-[0.08em]"
"absolute inset-0 flex size-full items-center justify-center bg-[inherit] text-inherit font-medium uppercase tracking-[0.08em]",
"transition-[opacity,transform,background-color] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"motion-reduce:transition-none"
]);
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Badge } from "./badge";
import { badgeVariants } from "./badge.variants";
describe("Badge", () => {
it("renders with root and label slots plus data hooks", () => {
@@ -32,4 +33,12 @@ describe("Badge", () => {
expect(link).toHaveAttribute("data-slot", "root");
expect(link).toHaveAttribute("data-tone", "primary");
});
it("includes tactile chip hooks for hover and pressed states", () => {
const subtle = badgeVariants({ variant: "subtle" });
expect(subtle).toContain("aria-[pressed=true]:before:opacity-100");
expect(subtle).toContain("motion-safe:hover:-translate-y-px");
expect(subtle).toContain("before:translate-x-[-18%]");
});
});
+7 -4
View File
@@ -3,8 +3,11 @@ import { getMotionRecipeClassNames } from "../lib/motion";
export const badgeVariants = cva(
[
"inline-flex shrink-0 items-center justify-center gap-1 whitespace-nowrap rounded-[var(--ui-control-radius)] border font-medium [border-width:var(--ui-input-border-width)]",
"relative isolate inline-flex shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-[var(--ui-control-radius)] border font-medium [border-width:var(--ui-input-border-width)]",
"outline-none select-none",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] before:bg-[radial-gradient(circle_at_left,color-mix(in_oklch,white_54%,transparent),transparent_74%)] before:opacity-0 before:translate-x-[-18%] before:transition-[opacity,transform] before:duration-[var(--dur-fast)] before:ease-[var(--ease-standard)] before:content-['']",
"[&>*]:relative [&>*]:z-[1]",
"aria-[pressed=true]:before:opacity-100 aria-[pressed=true]:before:translate-x-0 aria-[pressed=true]:shadow-[0_10px_20px_color-mix(in_oklch,var(--color-primary)_10%,transparent)]",
getMotionRecipeClassNames("transition", "ring")
],
{
@@ -15,11 +18,11 @@ export const badgeVariants = cva(
},
variant: {
subtle:
"border-[var(--ui-control-border)] bg-[var(--ui-control-bg)] text-[var(--color-foreground)] shadow-[var(--ui-control-shadow)]",
"border-[var(--ui-control-border)] bg-[var(--ui-control-bg)] text-[var(--color-foreground)] shadow-[var(--ui-control-shadow)] motion-safe:hover:-translate-y-px motion-safe:hover:before:opacity-100 motion-safe:hover:before:translate-x-0 motion-safe:hover:shadow-[0_10px_20px_color-mix(in_oklch,var(--color-primary)_8%,transparent),var(--ui-control-shadow)] motion-reduce:hover:translate-y-0",
solid:
"border-transparent bg-[var(--color-foreground)] text-[var(--color-background)]",
"border-transparent bg-[var(--color-foreground)] text-[var(--color-background)] shadow-[inset_0_1px_0_color-mix(in_oklch,white_28%,transparent)] motion-safe:hover:-translate-y-px motion-safe:hover:before:opacity-72 motion-safe:hover:before:translate-x-0 motion-safe:hover:shadow-[0_10px_18px_color-mix(in_oklch,var(--color-foreground)_14%,transparent)] motion-reduce:hover:translate-y-0",
outline:
"border-[var(--ui-control-border)] bg-transparent text-[var(--color-foreground)]"
"border-[var(--ui-control-border)] bg-transparent text-[var(--color-foreground)] motion-safe:hover:-translate-y-px motion-safe:hover:bg-[color-mix(in_oklch,var(--ui-control-bg)_72%,white_28%)] motion-safe:hover:before:opacity-100 motion-safe:hover:before:translate-x-0 motion-safe:hover:shadow-[0_10px_20px_color-mix(in_oklch,var(--color-primary)_8%,transparent)] motion-reduce:hover:translate-y-0"
},
tone: {
neutral: "",
+12 -2
View File
@@ -36,13 +36,23 @@ describe("Card", () => {
expect(screen.getByText("Updated 2h ago")).toHaveAttribute("data-slot", "footer");
});
it("supports interactive state hooks", () => {
it("defaults to the interactive state hook", () => {
render(
<Card data-testid="card" interactive>
<Card data-testid="card">
<CardContent>Hover capable</CardContent>
</Card>
);
expect(screen.getByTestId("card")).toHaveAttribute("data-interactive", "");
});
it("supports explicitly opting out of interactive treatment", () => {
render(
<Card data-testid="card" interactive={false}>
<CardContent>Static detail</CardContent>
</Card>
);
expect(screen.getByTestId("card")).not.toHaveAttribute("data-interactive");
});
});
+4 -2
View File
@@ -24,15 +24,17 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
},
ref
) {
const resolvedInteractive = interactive ?? true;
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
interactive,
interactive: resolvedInteractive,
tone
})}
className={cn(cardVariants({ interactive, tone }), className)}
className={cn(cardVariants({ interactive: resolvedInteractive, tone }), className)}
ref={ref}
/>
);
+10 -3
View File
@@ -19,13 +19,20 @@ export const cardVariants = cva(
},
interactive: {
false: "",
true:
"hover:translate-y-[var(--ui-card-hover-translate)] hover:scale-[var(--ui-card-hover-scale,1)] hover:shadow-[var(--ui-card-hover-shadow)]"
true: [
"relative isolate overflow-hidden",
"before:pointer-events-none before:absolute before:inset-px before:rounded-[calc(var(--ui-card-radius)-1px)] before:bg-[radial-gradient(circle_at_top,color-mix(in_oklch,var(--color-primary-container)_28%,transparent),transparent_62%)] before:opacity-0 before:content-['']",
"before:transition-opacity before:duration-[var(--dur-base)] before:ease-[var(--ease-standard)]",
"hover:border-[color-mix(in_oklch,var(--color-primary)_16%,var(--ui-card-default-border))]",
"hover:translate-y-[calc(var(--ui-card-hover-translate)*0.45)] hover:scale-[var(--ui-card-hover-scale,1)] hover:shadow-[var(--ui-card-hover-shadow)] hover:before:opacity-100",
"focus-within:translate-y-[calc(var(--ui-card-hover-translate)*0.2)] focus-within:shadow-[var(--ui-card-hover-shadow)] focus-within:before:opacity-80",
"motion-reduce:hover:translate-y-0 motion-reduce:hover:scale-100 motion-reduce:focus-within:translate-y-0"
]
}
},
defaultVariants: {
tone: "default",
interactive: false
interactive: true
}
}
);
+227
View File
@@ -0,0 +1,227 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import {
Chart,
ChartChange,
ChartDescription,
ChartHeader,
ChartHeaderAside,
ChartHeaderLeading,
ChartMetrics,
ChartTitle,
ChartValue,
type ChartSeries
} from "./chart";
type RevenueDatum = {
gross: number;
month: string;
revenue: number;
};
const rows: RevenueDatum[] = [
{ gross: 61200, month: "Jan", revenue: 84200 },
{ gross: 73400, month: "Feb", revenue: 96350 },
{ gross: 82500, month: "Mar", revenue: 118420 },
{ gross: 78220, month: "Apr", revenue: 109860 }
];
function formatCurrency(value: number) {
return new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 0,
style: "currency"
}).format(value);
}
const series: ChartSeries<RevenueDatum>[] = [
{
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",
tone: "neutral",
valueFormatter: formatCurrency
}
];
describe("Chart", () => {
it("renders the composed header, plot, tooltip, and legend surfaces", () => {
const { container } = render(
<Chart
data={rows}
defaultActiveIndex={1}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
header={
<ChartHeader>
<ChartHeaderLeading>
<ChartTitle>Sales Impact</ChartTitle>
<ChartDescription>
Revenue compared to gross margin over the current month set.
</ChartDescription>
</ChartHeaderLeading>
<ChartHeaderAside>
<ChartMetrics>
<ChartValue>{formatCurrency(96350)}</ChartValue>
<ChartChange>Live forecast</ChartChange>
</ChartMetrics>
</ChartHeaderAside>
</ChartHeader>
}
series={series}
showLegend
title="Sales Impact"
/>
);
expect(screen.getByText("Sales Impact").closest('[data-slot="label"]')).toBeInTheDocument();
expect(
screen.getByText("Revenue compared to gross margin over the current month set.")
).toHaveAttribute("data-slot", "description");
const tooltip = document.querySelector('[data-slot="tooltip"]');
const legend = document.querySelector('[data-slot="legend"]');
expect(tooltip).not.toBeNull();
expect(legend).not.toBeNull();
expect(screen.getByText("Sales Impact").closest('[data-slot="leading"]')).toBeInTheDocument();
expect(screen.getByText("Live forecast").closest('[data-slot="aside"]')).toBeInTheDocument();
expect(within(tooltip as HTMLElement).getByText("Feb 2025")).toBeInTheDocument();
expect(within(legend as HTMLElement).getByText("Revenue").closest('[data-slot="item"]')).toBeInTheDocument();
expect(within(legend as HTMLElement).getByText("Gross")).toBeInTheDocument();
expect(container.querySelector('line[stroke-width="1.5"]')).toBeInTheDocument();
expect(container.querySelectorAll("svg circle")).toHaveLength(4);
});
it("keeps the legacy convenience header props working", () => {
const longValueChange =
"Legend values follow the active point when the chart is interactive.";
render(
<Chart
data={rows}
defaultActiveIndex={1}
description="Revenue compared to gross margin over the current month set."
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
series={series}
title="Legacy chart"
value={formatCurrency(96350)}
valueChange={longValueChange}
/>
);
expect(screen.getByText("Legacy chart")).toHaveAttribute("data-slot", "label");
expect(screen.getByText(longValueChange)).toHaveAttribute("data-slot", "delta");
expect(screen.getByText(longValueChange)).toHaveClass("max-w-full");
expect(
screen
.getAllByText(formatCurrency(96350))
.find((element) => element.getAttribute("data-slot") === "value")
).toBeInTheDocument();
});
it("updates the active point summary when a chart point is hovered", async () => {
const user = userEvent.setup();
render(
<Chart
data={rows}
defaultActiveIndex={0}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
series={series}
title="Hoverable chart"
/>
);
const tooltip = () => document.querySelector('[data-slot="tooltip"]') as HTMLElement;
expect(within(tooltip()).getByText("Jan 2025")).toBeInTheDocument();
await user.hover(screen.getByRole("button", { name: /mar 2025/i }));
expect(within(tooltip()).getByText("Mar 2025")).toBeInTheDocument();
expect(within(tooltip()).getByText(formatCurrency(118420))).toBeInTheDocument();
});
it("supports controlled active state and only emits callbacks on interaction", async () => {
const user = userEvent.setup();
const onActiveIndexChange = vi.fn();
render(
<Chart
activeIndex={0}
data={rows}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
onActiveIndexChange={onActiveIndexChange}
series={series}
title="Controlled chart"
/>
);
await user.hover(screen.getByRole("button", { name: /apr 2025/i }));
expect(onActiveIndexChange).toHaveBeenCalledWith(3);
const tooltip = document.querySelector('[data-slot="tooltip"]') as HTMLElement;
expect(within(tooltip).getByText("Jan 2025")).toBeInTheDocument();
expect(within(tooltip).queryByText("Apr 2025")).not.toBeInTheDocument();
});
it("keeps the active comparison usable when document motion is static", async () => {
const user = userEvent.setup();
document.documentElement.dataset.motion = "static";
render(
<Chart
data={rows}
defaultActiveIndex={0}
getActiveLabel={(datum) => `${datum.month} 2025`}
getXAxisLabel={(datum) => datum.month}
series={series}
title="Static motion chart"
/>
);
const tooltip = () => document.querySelector('[data-slot="tooltip"]') as HTMLElement;
expect(within(tooltip()).getByText("Jan 2025")).toBeInTheDocument();
await user.hover(screen.getByRole("button", { name: /apr 2025/i }));
expect(within(tooltip()).getByText("Apr 2025")).toBeInTheDocument();
delete document.documentElement.dataset.motion;
});
it("renders the empty contract without collapsing the panel", () => {
render(
<Chart
data={[]}
empty="No series are visible in the current scope."
getXAxisLabel={() => ""}
series={series}
title="Empty chart"
/>
);
expect(screen.getByText("Empty chart")).toBeInTheDocument();
expect(screen.getByText("No series are visible in the current scope.")).toHaveAttribute(
"data-slot",
"empty"
);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const chartVariants = cva(
[
"grid gap-5 rounded-[var(--ui-card-radius)] border [border-width:var(--ui-card-border-width)]",
"px-5 py-5 text-[var(--color-card-foreground)] shadow-[var(--ui-card-default-shadow)] sm:px-6 sm:py-6",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
tone: {
default:
"border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)]",
accent:
"border-[var(--ui-card-accent-border)] bg-[var(--ui-card-accent-bg)] shadow-[var(--ui-card-accent-shadow)]"
}
},
defaultVariants: {
tone: "default"
}
}
);
export const chartHeaderVariants = cva(
"grid gap-4 md:grid-cols-[minmax(0,1fr)_minmax(13rem,18rem)] md:items-start"
);
export const chartHeaderLeadingVariants = cva("grid min-w-0 gap-2");
export const chartHeaderAsideVariants = cva(
"grid min-w-0 gap-3 justify-self-start md:justify-self-end md:justify-items-end"
);
export const chartEyebrowVariants = cva(
"text-[0.72rem] font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
);
export const chartTitleVariants = cva(
"text-lg font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)] sm:text-[1.375rem]"
);
export const chartDescriptionVariants = cva(
"max-w-[42rem] text-sm leading-6 text-[var(--color-muted-foreground)]"
);
export const chartMetricGroupVariants = cva("grid min-w-0 gap-1 md:justify-items-end");
export const chartValueVariants = cva(
"max-w-full text-2xl font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)] md:text-right md:text-[2rem]"
);
export const chartChangeVariants = cva(
"flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 text-sm font-medium text-[var(--color-muted-foreground)] md:max-w-[18rem] md:justify-end md:text-right"
);
export const chartCanvasVariants = cva(
[
"relative overflow-hidden rounded-[calc(var(--ui-card-radius)-0.375rem)] border [border-width:var(--ui-card-border-width)]",
"border-[color-mix(in_oklch,var(--ui-card-default-border)_84%,transparent)]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-low)_86%,white_14%)_0%,color-mix(in_oklch,var(--color-surface-bright)_72%,var(--color-surface-container-low)_28%)_100%)]"
],
{
variants: {
interactive: {
false: "",
true:
"supports-[backdrop-filter:blur(0px)]:backdrop-blur-[2px]"
}
},
defaultVariants: {
interactive: true
}
}
);
export const chartTooltipVariants = cva(
[
"min-w-[10.5rem] max-w-[14rem] rounded-[calc(var(--ui-card-radius)-0.5rem)] origin-bottom will-change-transform",
"border border-[color-mix(in_oklch,var(--ui-card-default-border)_88%,transparent)]",
"bg-[color-mix(in_oklch,var(--color-surface-bright)_88%,white_12%)] px-3 py-2.5",
"shadow-[0_18px_42px_color-mix(in_oklch,var(--color-primary)_14%,transparent)]",
"backdrop-blur-sm"
]
);
export const chartLegendVariants = cva(
"flex flex-wrap items-center gap-2.5"
);
export const chartLegendItemVariants = cva(
[
"inline-flex min-h-9 items-center gap-2.5 rounded-[var(--radius-full)] border px-3 py-2",
"border-[color-mix(in_oklch,var(--color-border)_72%,transparent)]",
"bg-[color-mix(in_oklch,var(--color-surface-container-low)_74%,white_26%)] text-sm"
]
);
export const chartEmptyStateVariants = cva(
"grid min-h-72 place-items-center px-6 py-10 text-center text-sm leading-6 text-[var(--color-muted-foreground)]"
);
+4 -1
View File
@@ -12,16 +12,19 @@ describe("Checkbox", () => {
render(<Checkbox aria-label="Accept terms" onCheckedChange={onCheckedChange} />);
const checkbox = screen.getByRole("checkbox", { name: "Accept terms" });
const icon = checkbox.querySelector('[data-slot="icon"]');
expect(checkbox).toHaveAttribute("data-slot", "root");
expect(checkbox).toHaveAttribute("data-state", "unchecked");
expect(icon).toHaveAttribute("data-state", "unchecked");
await user.click(checkbox);
expect(checkbox).toBeChecked();
expect(checkbox).toHaveAttribute("data-state", "checked");
expect(onCheckedChange).toHaveBeenCalledWith(true);
expect(checkbox.querySelector('[data-slot="icon"] svg')).toBeInTheDocument();
expect(icon).toHaveAttribute("data-state", "checked");
expect(icon?.querySelector("svg")).toBeInTheDocument();
});
it("supports keyboard interaction", async () => {
+1
View File
@@ -29,6 +29,7 @@ export const Checkbox = forwardRef<
>
<CheckboxPrimitive.Indicator
{...createSlot("icon")}
forceMount
className={checkboxIndicatorVariants()}
>
<CheckIcon className="size-3.5" />
@@ -15,5 +15,9 @@ export const checkboxVariants = cva(
);
export const checkboxIndicatorVariants = cva([
"inline-flex items-center justify-center text-[0.8rem] leading-none"
"inline-flex items-center justify-center text-[0.8rem] leading-none",
"will-change-transform transition-[opacity,transform] duration-[var(--dur-fast)] ease-[var(--ease-emphasized)]",
"data-[state=unchecked]:scale-[0.72] data-[state=unchecked]:opacity-0",
"data-[state=checked]:scale-100 data-[state=checked]:opacity-100",
"motion-reduce:transition-none motion-reduce:data-[state=unchecked]:scale-100"
]);
+15 -1
View File
@@ -75,6 +75,12 @@ describe("Combobox", () => {
await waitFor(() => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
await user.click(trigger);
expect(await screen.findByRole("option", { name: /Legal review/i })).toHaveAttribute(
"data-selected",
""
);
});
it("supports a controlled value and reports updates", async () => {
@@ -186,6 +192,14 @@ describe("Combobox", () => {
await user.click(trigger);
expect(screen.getByText("Searching review lanes…")).toBeInTheDocument();
expect(document.querySelector('[data-slot="root"][data-loading]')).toHaveAttribute(
"data-loading",
""
);
expect(document.querySelector('[data-slot="root"][data-loading]')).toHaveAttribute(
"aria-busy",
"true"
);
expect(screen.getByText("Manage routing lanes")).toBeInTheDocument();
loadingView.unmount();
@@ -221,7 +235,7 @@ describe("Combobox", () => {
const refreshedSearchbox = screen.getByRole("searchbox", { name: "Search options" });
await user.clear(refreshedSearchbox);
await user.type(refreshedSearchbox, "security");
expect(screen.getByText("Create “security” as a new lane")).toBeInTheDocument();
expect(await screen.findByText("Create “security” as a new lane")).toBeInTheDocument();
await user.keyboard("{Tab}");
await waitFor(() => {
+98 -8
View File
@@ -1,4 +1,5 @@
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
forwardRef,
useEffect,
@@ -32,6 +33,32 @@ function mergeIds(...ids: Array<string | undefined>) {
return value.length > 0 ? value : undefined;
}
function useMotionDisabled() {
const prefersReducedMotion = useReducedMotion();
const [isStaticMotion, setIsStaticMotion] = useState(false);
useEffect(() => {
if (typeof document === "undefined") {
return;
}
const syncMotionMode = () => {
setIsStaticMotion(document.documentElement.dataset.motion === "static");
};
syncMotionMode();
const observer = new MutationObserver(syncMotionMode);
observer.observe(document.documentElement, {
attributeFilter: ["data-motion"]
});
return () => observer.disconnect();
}, []);
return prefersReducedMotion || isStaticMotion;
}
function getNextEnabledIndex(
items: ComboboxItem[],
currentIndex: number,
@@ -122,6 +149,7 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
const searchRef = useRef<HTMLInputElement>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [activeIndex, setActiveIndex] = useState(-1);
const disableMotion = useMotionDisabled();
const resolvedOpen = open ?? uncontrolledOpen;
const resolvedValue = value ?? uncontrolledValue;
const resolvedSearchValue = searchValue ?? uncontrolledSearchValue;
@@ -158,7 +186,15 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
return haystack.includes(query);
});
}, [filter, items, resolvedSearchValue]);
const listboxId = filteredItems.length > 0 ? `${controlId}-listbox` : undefined;
const listboxId = !loading && filteredItems.length > 0 ? `${controlId}-listbox` : undefined;
const presenceTransition = {
duration: disableMotion ? 0.01 : 0.18,
ease: [0.22, 1, 0.36, 1]
} as const;
const rowTransition = {
duration: disableMotion ? 0.01 : 0.16,
ease: [0.22, 1, 0.36, 1]
} as const;
const groupedItems = useMemo(() => {
const groups = new Map<string, ComboboxItem[]>();
@@ -341,9 +377,11 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
{...createDataAttributes({
disabled: resolvedDisabled,
invalid: resolvedInvalid,
loading,
open: resolvedOpen,
value: resolvedValue || undefined
})}
aria-busy={loading || undefined}
className="grid gap-2"
>
<PopoverPrimitive.Root onOpenChange={setOpenState} open={resolvedOpen}>
@@ -402,18 +440,42 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
value={resolvedSearchValue}
/>
</div>
<AnimatePresence initial={false} mode="wait">
{loading ? (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: disableMotion ? 0 : -4 }}
initial={{ opacity: 0, y: disableMotion ? 0 : 6 }}
key={`loading:${resolvedSearchValue}`}
transition={presenceTransition}
>
<div {...createSlot("empty")} className={comboboxEmptyVariants()}>
<span className="inline-flex items-center gap-2">
<SpinnerIcon className="size-4 animate-spin" />
<SpinnerIcon className="size-4 animate-spin text-[var(--color-primary)]" />
{loadingMessage}
</span>
</div>
</motion.div>
) : filteredItems.length === 0 ? (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: disableMotion ? 0 : -4 }}
initial={{ opacity: 0, y: disableMotion ? 0 : 6 }}
key={`empty:${resolvedSearchValue.trim()}`}
transition={presenceTransition}
>
<div {...createSlot("empty")} className={comboboxEmptyVariants()}>
{renderedEmptyMessage}
</div>
</motion.div>
) : (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: disableMotion ? 0 : -4 }}
initial={{ opacity: 0, y: disableMotion ? 0 : 6 }}
key={`results:${resolvedSearchValue.trim()}:${filteredItems.map((item) => item.value).join("|")}`}
transition={presenceTransition}
>
<div
{...createSlot("list")}
className={comboboxListVariants()}
@@ -459,31 +521,59 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
role="option"
type="button"
>
<motion.span
animate={
isSelected
? { opacity: 1, scale: 1, x: 0 }
: isActive
? { opacity: 0.78, scale: 1, x: 0 }
: { opacity: 0, scale: 0.985, x: disableMotion ? 0 : -10 }
}
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-[inherit] bg-[radial-gradient(circle_at_left,color-mix(in_oklch,var(--color-primary-container)_48%,transparent),transparent_74%)]"
transition={rowTransition}
/>
<span
{...createSlot("icon")}
aria-hidden="true"
className={cn(
"mt-0.5 inline-flex size-4 shrink-0 items-center justify-center text-[var(--color-primary)]",
!isSelected && "opacity-0"
"mt-0.5 inline-flex size-4 shrink-0 items-center justify-center text-[var(--color-primary)] transition-[opacity,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
isSelected ? "scale-100 opacity-100" : "scale-75 opacity-0"
)}
>
<CheckIcon className="size-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{item.label}</span>
<motion.span
animate={{
x: isSelected ? 1.5 : isActive ? 0.75 : 0
}}
className="min-w-0 flex-1"
transition={rowTransition}
>
<span
{...createSlot("label")}
className="block truncate font-medium"
>
{item.label}
</span>
{item.description ? (
<span className="mt-0.5 block text-xs leading-5 text-[var(--color-muted-foreground)]">
<span
{...createSlot("description")}
className="mt-0.5 block text-xs leading-5 text-[var(--color-muted-foreground)]"
>
{item.description}
</span>
) : null}
</span>
</motion.span>
</button>
);
})}
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{footer ? (
<div {...createSlot("footer")} className={comboboxFooterVariants()}>
{footer}
@@ -27,26 +27,36 @@ export const comboboxSearchVariants = cva([
]);
export const comboboxListVariants = cva([
"max-h-[18rem] overflow-y-auto p-1"
"max-h-[18rem] overflow-y-auto p-1.5 transition-[opacity,transform] duration-[var(--dur-base)] ease-[var(--ease-standard)]"
]);
export const comboboxGroupVariants = cva([
"grid gap-1"
"grid gap-1.5 p-0.5 transition-opacity duration-[var(--dur-fast)] ease-[var(--ease-standard)]"
]);
export const comboboxLabelVariants = cva([
"px-2.5 py-2 text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
"px-3 py-1.5 text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
]);
export const comboboxItemVariants = cva([
"relative flex w-full cursor-default select-none items-start gap-3 rounded-[var(--ui-control-radius)] px-3 py-2 text-left text-sm text-[var(--color-foreground)] outline-none",
"transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"data-[active=true]:bg-[var(--ui-control-bg)] data-[selected=true]:bg-[color-mix(in_oklch,var(--color-primary)_10%,var(--ui-panel-bg))]",
"data-[selected=true]:text-[var(--color-foreground)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-45"
"relative isolate flex w-full cursor-default select-none items-start gap-3 overflow-hidden rounded-[calc(var(--ui-control-radius)+0.1rem)] border border-transparent px-3 py-2.5 text-left text-sm text-[var(--color-foreground)] outline-none",
"bg-transparent shadow-[inset_0_1px_0_transparent]",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] before:bg-[radial-gradient(circle_at_left,color-mix(in_oklch,var(--color-primary-container)_42%,transparent),transparent_74%)] before:opacity-0 before:translate-x-[-12%] before:transition-[opacity,transform] before:duration-[var(--dur-base)] before:ease-[var(--ease-emphasized)] before:content-['']",
"[&>*]:relative [&>*]:z-[1]",
"transition-[color,background-color,border-color,box-shadow,transform,opacity] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"hover:-translate-y-px hover:border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] hover:bg-[color-mix(in_oklch,var(--color-surface-container-high)_72%,white_28%)] hover:shadow-[inset_0_1px_0_color-mix(in_oklch,white_52%,transparent)] hover:before:opacity-72 hover:before:translate-x-0",
"data-[active=true]:-translate-y-px data-[active=true]:translate-x-[1px] data-[active=true]:border-[color-mix(in_oklch,var(--color-primary)_14%,var(--color-border))] data-[active=true]:bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-surface-container-high)_84%,white_16%),color-mix(in_oklch,var(--ui-control-bg)_82%,white_18%))] data-[active=true]:shadow-[inset_0_1px_0_color-mix(in_oklch,white_54%,transparent),0_10px_22px_color-mix(in_oklch,var(--color-primary)_8%,transparent)] data-[active=true]:before:opacity-80 data-[active=true]:before:translate-x-0",
"data-[selected=true]:translate-x-[2px] data-[selected=true]:border-[color-mix(in_oklch,var(--color-primary)_22%,var(--color-border))] data-[selected=true]:bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_56%,white_44%),color-mix(in_oklch,var(--ui-control-bg)_74%,white_26%))] data-[selected=true]:shadow-[inset_0_1px_0_color-mix(in_oklch,white_60%,transparent),0_12px_24px_color-mix(in_oklch,var(--color-primary)_10%,transparent)] data-[selected=true]:before:opacity-100 data-[selected=true]:before:translate-x-0",
"[&_[data-slot=label]]:transition-colors [&_[data-slot=label]]:duration-[var(--dur-fast)] [&_[data-slot=description]]:transition-colors [&_[data-slot=description]]:duration-[var(--dur-fast)]",
"[&[data-active]_[data-slot=description]]:text-[color-mix(in_oklch,var(--color-foreground)_74%,var(--color-muted-foreground))]",
"[&[data-selected]_[data-slot=description]]:text-[color-mix(in_oklch,var(--color-primary)_46%,var(--color-muted-foreground))]",
"motion-reduce:hover:-translate-y-0 motion-reduce:data-[active=true]:-translate-y-0 motion-reduce:data-[active=true]:translate-x-0 motion-reduce:data-[selected=true]:translate-x-0",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-45",
getMotionRecipeClassNames("ring")
]);
export const comboboxEmptyVariants = cva([
"px-3 py-6 text-center text-sm text-[var(--color-muted-foreground)]"
"mx-1.5 my-1.5 rounded-[calc(var(--ui-control-radius)+0.15rem)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_76%,white_24%)] px-4 py-6 text-center text-sm text-[var(--color-muted-foreground)] shadow-[inset_0_1px_0_color-mix(in_oklch,white_44%,transparent)] motion-enter-fade motion-enter-rise"
]);
export const comboboxFooterVariants = cva([
+13 -1
View File
@@ -40,13 +40,23 @@ describe("Command", () => {
const root = document.querySelector('[data-slot="root"]');
const input = screen.getByPlaceholderText("Search actions");
const control = input.closest('[data-slot="control"]');
expect(root).toHaveAttribute("data-slot", "root");
expect(control).toBeInTheDocument();
expect(input).toHaveAttribute("data-slot", "input");
expect(control?.querySelector('[data-slot="prefix"]')).toBeTruthy();
expect(document.querySelector('[data-slot="list"]')).toBeInTheDocument();
expect(screen.getByText("Open legal review").closest('[data-slot="item"]')).toBeInTheDocument();
expect(screen.getByText("G L")).toHaveAttribute("data-slot", "shortcut");
await user.click(input);
await user.keyboard("{ArrowDown}");
expect(screen.getByText("Open legal review").closest('[data-slot="item"]')).toHaveAttribute(
"data-selected",
"true"
);
await user.type(input, "zzz");
expect(await screen.findByText("No results.")).toHaveAttribute("data-slot", "empty");
});
@@ -113,7 +123,9 @@ describe("Command", () => {
</Command>
);
expect(screen.getByText("Searching workspace…")).toHaveAttribute("data-slot", "loading");
expect(document.querySelector('[data-slot="root"]')).toHaveAttribute("data-loading", "");
expect(document.querySelector('[data-slot="root"]')).toHaveAttribute("aria-busy", "true");
expect(screen.getByText("Searching workspace…").closest('[data-slot="loading"]')).toBeInTheDocument();
expect(screen.getByText("Manage commands")).toBeInTheDocument();
});
+56 -6
View File
@@ -1,7 +1,10 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
forwardRef,
useEffect,
useState,
type ComponentPropsWithoutRef,
type ElementRef,
type ReactNode
@@ -28,6 +31,8 @@ import {
DialogHeader,
DialogTitle
} from "./dialog";
import { InputGroup, InputGroupPrefix } from "./input-group";
import { SpinnerIcon } from "../lib/icons";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts";
@@ -46,6 +51,32 @@ function SearchIcon() {
);
}
function useMotionDisabled() {
const prefersReducedMotion = useReducedMotion();
const [isStaticMotion, setIsStaticMotion] = useState(false);
useEffect(() => {
if (typeof document === "undefined") {
return;
}
const syncMotionMode = () => {
setIsStaticMotion(document.documentElement.dataset.motion === "static");
};
syncMotionMode();
const observer = new MutationObserver(syncMotionMode);
observer.observe(document.documentElement, {
attributeFilter: ["data-motion"]
});
return () => observer.disconnect();
}, []);
return prefersReducedMotion || isStaticMotion;
}
export type CommandProps = ComponentPropsWithoutRef<typeof CommandPrimitive> & {
footer?: ReactNode;
loading?: boolean;
@@ -64,21 +95,41 @@ export const Command = forwardRef<ElementRef<typeof CommandPrimitive>, CommandPr
},
ref
) {
const disableMotion = useMotionDisabled();
return (
<CommandPrimitive
{...props}
{...createSlot("root")}
{...createDataAttributes({ loading })}
aria-busy={loading || undefined}
className={cn(commandVariants(), className)}
ref={ref}
>
<AnimatePresence initial={false}>
{loading ? (
<motion.div
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: disableMotion ? 0 : -4 }}
initial={{ opacity: 0, y: disableMotion ? 0 : 6 }}
transition={{
duration: disableMotion ? 0.01 : 0.16,
ease: [0.22, 1, 0.36, 1]
}}
>
<div
{...createSlot("loading")}
className={commandLoadingVariants()}
>
{loadingMessage}
<SpinnerIcon
aria-hidden="true"
className="size-4 animate-spin text-[var(--color-primary)]"
/>
<span>{loadingMessage}</span>
</div>
</motion.div>
) : null}
</AnimatePresence>
{children}
{footer ? (
<div
@@ -150,18 +201,17 @@ export const CommandInput = forwardRef<
CommandInputProps
>(function CommandInput({ className, wrapperClassName, ...props }, ref) {
return (
<div
{...createSlot("control")}
className={cn(commandInputWrapperVariants(), wrapperClassName)}
>
<InputGroup className={cn(commandInputWrapperVariants(), wrapperClassName)} size="lg">
<InputGroupPrefix>
<SearchIcon />
</InputGroupPrefix>
<CommandPrimitive.Input
{...props}
{...createSlot("input")}
className={cn(commandInputVariants(), className)}
ref={ref}
/>
</div>
</InputGroup>
);
});
+27 -12
View File
@@ -2,8 +2,10 @@ import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const commandVariants = cva([
"flex h-full w-full flex-col overflow-hidden rounded-[var(--ui-panel-radius)] border border-[var(--ui-panel-border)]",
"relative flex h-full w-full flex-col overflow-hidden rounded-[var(--ui-panel-radius)] border border-[var(--ui-panel-border)]",
"bg-[var(--ui-panel-bg)] text-[var(--color-card-foreground)] shadow-[var(--ui-panel-shadow)] [border-width:var(--ui-panel-border-width)] backdrop-blur-[var(--ui-panel-backdrop-blur)]",
"[&_[data-slot=list]]:transition-[opacity,filter] [&_[data-slot=list]]:duration-[var(--dur-base)] [&_[data-slot=list]]:ease-[var(--ease-standard)]",
"data-[loading]:[&_[data-slot=list]]:opacity-70 data-[loading]:[&_[data-slot=list]]:saturate-[0.98]",
getMotionRecipeClassNames("transition", "ring")
]);
@@ -13,7 +15,10 @@ export const commandDialogContentVariants = cva([
]);
export const commandInputWrapperVariants = cva([
"flex items-center gap-3 border-b border-[var(--ui-panel-border)] px-4"
"rounded-none border-0 border-b border-[var(--ui-panel-border)] bg-transparent px-4 shadow-none backdrop-blur-none",
"data-[disabled]:bg-transparent data-[readonly]:bg-transparent",
"focus-within:-translate-y-0 focus-within:border-[var(--ui-panel-border)]",
"focus-within:shadow-none focus-within:ring-0 focus-within:ring-offset-0"
]);
export const commandInputVariants = cva([
@@ -22,34 +27,44 @@ export const commandInputVariants = cva([
]);
export const commandListVariants = cva([
"max-h-[22rem] overflow-y-auto overflow-x-hidden p-2"
"max-h-[22rem] overflow-y-auto overflow-x-hidden p-2.5 transition-[opacity,transform] duration-[var(--dur-base)] ease-[var(--ease-standard)]"
]);
export const commandLoadingVariants = cva([
"px-4 py-8 text-sm text-[var(--color-muted-foreground)]"
"mx-3 mt-2 flex items-center gap-2.5 rounded-[calc(var(--ui-control-radius)+0.15rem)] border border-[color-mix(in_oklch,var(--color-primary)_16%,var(--ui-panel-border))] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_34%,white_66%),color-mix(in_oklch,var(--ui-panel-bg)_88%,white_12%))] px-3.5 py-2.5 text-sm text-[var(--color-foreground)] shadow-[inset_0_1px_0_color-mix(in_oklch,white_54%,transparent),0_10px_22px_color-mix(in_oklch,var(--color-primary)_8%,transparent)]"
]);
export const commandEmptyVariants = cva([
"py-10 text-center text-sm text-[var(--color-muted-foreground)]"
"mx-2 my-2 rounded-[calc(var(--ui-control-radius)+0.15rem)] border border-[color-mix(in_oklch,var(--color-border)_74%,transparent)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_76%,white_24%)] px-4 py-7 text-center text-sm text-[var(--color-muted-foreground)] shadow-[inset_0_1px_0_color-mix(in_oklch,white_44%,transparent)] motion-enter-fade motion-enter-rise"
]);
export const commandGroupVariants = cva([
"overflow-hidden p-1 text-[var(--color-foreground)]",
"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5",
"[&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:py-1.5",
"[&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
"[&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[var(--tracking-caps)]",
"[&_[cmdk-group-heading]]:text-[var(--color-muted-foreground)]"
"[&_[cmdk-group-heading]]:text-[var(--color-muted-foreground)]",
"[&_[cmdk-group-items]]:grid [&_[cmdk-group-items]]:gap-1"
]);
export const commandItemVariants = cva([
"relative flex cursor-default select-none items-center gap-3 rounded-[var(--ui-control-radius)] px-3 py-2 text-sm outline-none",
"text-[var(--color-foreground)] transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"data-[selected=true]:bg-[var(--ui-control-bg)] data-[selected=true]:text-[var(--color-foreground)]",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-45"
"relative isolate flex min-w-0 cursor-default select-none items-center gap-3 overflow-hidden rounded-[calc(var(--ui-control-radius)+0.1rem)] border border-transparent px-3 py-2.5 text-sm outline-none",
"bg-transparent text-[var(--color-foreground)] shadow-[inset_0_1px_0_transparent]",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[inherit] before:bg-[radial-gradient(circle_at_left,color-mix(in_oklch,var(--color-primary-container)_42%,transparent),transparent_74%)] before:opacity-0 before:translate-x-[-12%] before:transition-[opacity,transform] before:duration-[var(--dur-base)] before:ease-[var(--ease-emphasized)] before:content-['']",
"[&>*]:relative [&>*]:z-[1]",
"transition-[color,background-color,border-color,box-shadow,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ui-panel-bg)]",
"hover:-translate-y-px hover:border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] hover:bg-[color-mix(in_oklch,var(--color-surface-container-high)_72%,white_28%)] hover:shadow-[inset_0_1px_0_color-mix(in_oklch,white_52%,transparent)] hover:before:opacity-72 hover:before:translate-x-0",
"data-[selected=true]:-translate-y-px data-[selected=true]:translate-x-[2px] data-[selected=true]:border-[color-mix(in_oklch,var(--color-primary)_20%,var(--color-border))] data-[selected=true]:bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_58%,white_42%),color-mix(in_oklch,var(--ui-control-bg)_76%,white_24%))] data-[selected=true]:shadow-[inset_0_1px_0_color-mix(in_oklch,white_60%,transparent),0_12px_24px_color-mix(in_oklch,var(--color-primary)_10%,transparent)] data-[selected=true]:before:opacity-100 data-[selected=true]:before:translate-x-0",
"[&_[data-slot=shortcut]]:transition-colors [&_[data-slot=shortcut]]:duration-[var(--dur-fast)] [&_[data-slot=shortcut]]:ease-[var(--ease-standard)]",
"[&[data-selected=true]_[data-slot=shortcut]]:text-[color-mix(in_oklch,var(--color-primary)_72%,var(--color-foreground))]",
"motion-reduce:hover:-translate-y-0 motion-reduce:data-[selected=true]:-translate-y-0 motion-reduce:data-[selected=true]:translate-x-0",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-45",
getMotionRecipeClassNames("ring")
]);
export const commandSeparatorVariants = cva([
"-mx-1 my-1 h-px bg-[var(--color-border)]"
"-mx-0.5 my-1.5 h-px bg-[color-mix(in_oklch,var(--color-border)_82%,transparent)]"
]);
export const commandShortcutVariants = cva([
+22 -5
View File
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
import { DataTable, type DataTableColumn } from "./data-table";
import { DataTable, DataTableSearch, type DataTableColumn } from "./data-table";
type ReleaseRow = {
id: string;
@@ -103,11 +103,13 @@ describe("DataTable", () => {
/>
);
const search = screen.getByRole("searchbox", { name: "Search rows" });
const searchControl = search.closest('[data-slot="control"]');
expect(screen.getByRole("table").closest('[data-slot="root"]')).toBeInTheDocument();
expect(screen.getByRole("searchbox", { name: "Search rows" })).toHaveAttribute(
"data-slot",
"input"
);
expect(search).toHaveAttribute("data-slot", "input");
expect(searchControl).toBeInTheDocument();
expect(searchControl?.querySelector('[data-slot="prefix"]')).toBeTruthy();
expect(
screen.getByRole("button", { name: "Create lane" }).closest('[data-slot="actions"]')
).toBeInTheDocument();
@@ -207,6 +209,21 @@ describe("DataTable", () => {
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
});
it("keeps search input and wrapper className semantics distinct", () => {
render(
<DataTableSearch
aria-label="Search rows"
className="search-input-override"
wrapperClassName="search-wrapper-override"
/>
);
const search = screen.getByRole("searchbox", { name: "Search rows" });
expect(search).toHaveClass("search-input-override");
expect(search.closest('[data-slot="control"]')).toHaveClass("search-wrapper-override");
});
it("renders loading status without dropping the table chrome", () => {
render(<DataTable columns={columns} loading rows={rows} />);
+69 -18
View File
@@ -44,6 +44,7 @@ import {
EmptyStateTitle
} from "./empty-state";
import { Input, type InputProps } from "./input";
import { InputGroup, InputGroupPrefix } from "./input-group";
import {
Sheet,
SheetContent,
@@ -585,7 +586,7 @@ function DataTableInner<TData>(
<DataTableToolbar>
<div className="flex flex-1 flex-wrap items-center gap-3">
{shouldRenderSearch ? (
<div className={dataTableSearchContainerVariants()}>
<div className={cn(dataTableSearchContainerVariants())}>
<DataTableSearch
aria-label={searchLabel}
onChange={(event) => {
@@ -650,12 +651,20 @@ function DataTableInner<TData>(
{selectionEnabled && selectedRows.length > 0 ? (
<DataTableSelectionBar>
<div className="grid gap-1">
<p className="text-[0.72rem] font-medium uppercase tracking-[var(--tracking-caps)] text-[color-mix(in_oklch,var(--color-primary)_68%,var(--color-foreground))]">
Selection ready
</p>
<div className="text-sm font-medium text-[var(--color-foreground)]">
{typeof selectionLabel === "function"
? selectionLabel(selectedRows)
: selectionLabel ?? `${selectedRows.length} selected`}
</div>
<div {...createSlot("actions")} className="flex flex-wrap items-center gap-2">
</div>
<div
{...createSlot("actions")}
className="flex flex-wrap items-center gap-2"
>
{selectionActions?.(selectedRows)}
<Button
size="sm"
@@ -701,19 +710,32 @@ function DataTableInner<TData>(
>
{header.isPlaceholder ? null : header.column.getCanSort() ? (
<button
className={[
"inline-flex w-full items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5",
"outline-none transition-colors duration-200 hover:bg-[var(--color-surface)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]",
align === "end" ? "justify-end" : align === "center" ? "justify-center" : "justify-start"
].join(" ")}
className={cn(
"group inline-flex w-full items-center gap-2 rounded-[calc(var(--ui-control-radius)-0.15rem)] px-2.5 py-2",
"outline-none transition-[background-color,box-shadow,color,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2",
"focus-visible:ring-offset-[color-mix(in_oklch,var(--ui-card-default-bg)_82%,white_18%)]",
sortState
? "-translate-y-px bg-[color-mix(in_oklch,var(--color-surface-bright)_52%,var(--color-primary-container))] text-[var(--color-foreground)] shadow-[inset_0_1px_0_color-mix(in_oklch,white_42%,transparent)]"
: "hover:-translate-y-px hover:bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_74%,white_26%)] hover:text-[var(--color-foreground)]",
align === "end"
? "justify-end"
: align === "center"
? "justify-center"
: "justify-start"
)}
onClick={header.column.getToggleSortingHandler()}
type="button"
>
<span>{flexRender(header.column.columnDef.header, header.getContext())}</span>
<span
aria-hidden="true"
className="inline-flex items-center justify-center text-[var(--color-muted-foreground)]"
className={cn(
"inline-flex items-center justify-center transition-[color,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
sortState
? "translate-x-px text-[var(--color-primary)]"
: "text-[var(--color-muted-foreground)] group-hover:translate-x-px group-hover:text-[var(--color-foreground)]"
)}
>
{sortState === "asc" ? (
<SortAscendingIcon className="size-3.5" />
@@ -795,13 +817,18 @@ function DataTableInner<TData>(
</div>
<DataTablePagination>
<div className="text-sm text-[var(--color-muted-foreground)]">
<div className="grid gap-0.5">
<span className="text-[0.72rem] font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Visible rows
</span>
<span className="text-sm font-medium text-[var(--color-foreground)]">
{pageStart}-{pageEnd} of {filteredRowCount}
</span>
</div>
<div className="flex flex-wrap items-center gap-3">
{resolvedPageSizeOptions.length > 1 ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_76%,white_24%)] px-2.5 py-1.5 shadow-[var(--ui-control-shadow)]">
<span className="text-sm text-[var(--color-muted-foreground)]">Rows</span>
<Select
value={String(currentPageSize)}
@@ -810,7 +837,7 @@ function DataTableInner<TData>(
setCurrentPageIndex(0);
}}
>
<SelectTrigger aria-label="Rows per page" className="w-[5.5rem]">
<SelectTrigger aria-label="Rows per page" className="h-9 w-[5.5rem] border-0 bg-transparent px-2 shadow-none">
<SelectValue placeholder={String(currentPageSize)} />
</SelectTrigger>
<SelectContent>
@@ -824,11 +851,11 @@ function DataTableInner<TData>(
</div>
) : null}
<div className="text-sm text-[var(--color-muted-foreground)]">
<div className="rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_72%,white_28%)] px-3 py-1.5 text-sm font-medium text-[var(--color-foreground)] shadow-[var(--ui-control-shadow)]">
Page {Math.min(currentPageIndex + 1, Math.max(pageCount, 1))} of {Math.max(pageCount, 1)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-[var(--radius-full)] bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_76%,white_24%)] p-1 shadow-[var(--ui-control-shadow)]">
<Button
disabled={!table.getCanPreviousPage()}
size="sm"
@@ -884,6 +911,21 @@ type DataTableComponent = <TData>(
export const DataTable = forwardRef(DataTableInner) as DataTableComponent;
function DataTableSearchIcon() {
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>
);
}
export type DataTableToolbarProps = ComponentPropsWithoutRef<"div">;
export const DataTableToolbar = forwardRef<HTMLDivElement, DataTableToolbarProps>(
@@ -907,18 +949,27 @@ export const DataTableFilters = forwardRef<HTMLDivElement, DataTableFiltersProps
<div
{...props}
{...createSlot("actions")}
className={cn("flex flex-wrap items-center gap-2", className)}
className={cn("flex flex-wrap items-center justify-end gap-2", className)}
ref={ref}
/>
);
}
);
export type DataTableSearchProps = Omit<InputProps, "size">;
export type DataTableSearchProps = Omit<InputProps, "size"> & {
wrapperClassName?: string;
};
export const DataTableSearch = forwardRef<HTMLInputElement, DataTableSearchProps>(
function DataTableSearch({ className, type = "search", ...props }, ref) {
return <Input {...props} className={className} ref={ref} type={type} />;
function DataTableSearch({ className, type = "search", wrapperClassName, ...props }, ref) {
return (
<InputGroup className={cn(wrapperClassName)}>
<InputGroupPrefix>
<DataTableSearchIcon />
</InputGroupPrefix>
<Input {...props} className={className} ref={ref} type={type} />
</InputGroup>
);
}
);
@@ -4,19 +4,30 @@ import { getMotionRecipeClassNames } from "../lib/motion";
export const dataTableRootVariants = cva("grid gap-4 text-[var(--color-foreground)]");
export const dataTableToolbarVariants = cva(
"flex flex-wrap items-center justify-between gap-3"
[
"flex flex-wrap items-center justify-between gap-3 rounded-[calc(var(--ui-card-radius)-0.25rem)]",
"border border-[color-mix(in_oklch,var(--ui-card-subtle-border)_84%,transparent)] [border-width:var(--ui-card-border-width)]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--ui-card-subtle-bg)_82%,white_18%),color-mix(in_oklch,var(--ui-card-default-bg)_88%,white_12%))]",
"px-3 py-3 shadow-[var(--ui-card-subtle-shadow)] sm:px-4",
"transition-[border-color,background-color,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"focus-within:border-[color-mix(in_oklch,var(--color-primary)_14%,var(--ui-card-subtle-border))]",
"focus-within:shadow-[0_16px_32px_color-mix(in_oklch,var(--color-primary)_8%,transparent),inset_0_1px_0_rgba(255,255,255,0.42)]"
]
);
export const dataTableContentVariants = cva(
[
"overflow-hidden rounded-[var(--ui-card-radius)] border border-[var(--ui-card-default-border)] bg-[var(--ui-card-default-bg)] shadow-[var(--ui-card-default-shadow)] [border-width:var(--ui-card-border-width)]",
"relative overflow-hidden rounded-[var(--ui-card-radius)] border border-[var(--ui-card-default-border)] [border-width:var(--ui-card-border-width)]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--ui-card-default-bg)_74%,white_26%),color-mix(in_oklch,var(--ui-card-default-bg)_92%,var(--ui-card-subtle-bg)))]",
"shadow-[var(--ui-card-default-shadow)]",
"before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-16 before:bg-[linear-gradient(180deg,color-mix(in_oklch,white_54%,transparent),transparent)] before:content-['']",
getMotionRecipeClassNames("transition", "ring")
],
{
variants: {
loading: {
false: "",
true: "opacity-90"
true: "opacity-95 saturate-[0.96]"
}
},
defaultVariants: {
@@ -28,12 +39,15 @@ export const dataTableContentVariants = cva(
export const dataTableTableVariants = cva("min-w-full border-collapse align-middle");
export const dataTableHeaderVariants = cva(
"bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_74%,var(--ui-card-default-bg))]"
[
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--ui-card-subtle-bg)_86%,white_14%),color-mix(in_oklch,var(--ui-card-default-bg)_82%,var(--ui-card-subtle-bg)))]",
"shadow-[inset_0_-1px_0_color-mix(in_oklch,var(--color-border)_74%,transparent)]"
]
);
export const dataTableHeaderCellVariants = cva(
[
"px-4 text-sm font-medium uppercase tracking-[var(--tracking-caps)]",
"relative px-4 first:pl-5 last:pr-5 align-middle text-[0.72rem] font-medium uppercase tracking-[var(--tracking-caps)]",
"text-[var(--color-muted-foreground)]"
],
{
@@ -44,8 +58,8 @@ export const dataTableHeaderCellVariants = cva(
end: "text-right"
},
density: {
comfortable: "py-3",
compact: "py-2.5 text-xs"
comfortable: "py-4",
compact: "py-3 text-[0.68rem]"
},
sortable: {
false: "",
@@ -64,18 +78,33 @@ export const dataTableBodyVariants = cva("");
export const dataTableRowVariants = cva(
[
"group/row",
"border-t border-[color-mix(in_oklch,var(--ui-card-default-border)_88%,transparent)]",
"transition-colors duration-200"
"transition-[background-color,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"[&>td]:transition-[background-color,box-shadow] [&>td]:duration-[var(--dur-base)] [&>td]:ease-[var(--ease-standard)]",
"motion-reduce:[&>td]:transition-none"
],
{
variants: {
interactive: {
false: "",
true: "hover:bg-[color-mix(in_oklch,var(--ui-card-subtle-bg)_72%,var(--ui-card-default-bg))]"
true:
[
"hover:bg-[linear-gradient(180deg,color-mix(in_oklch,var(--ui-card-subtle-bg)_78%,white_22%),color-mix(in_oklch,var(--ui-card-default-bg)_88%,var(--ui-card-subtle-bg)))]",
"hover:shadow-[inset_0_1px_0_color-mix(in_oklch,white_34%,transparent),0_10px_20px_color-mix(in_oklch,var(--color-primary)_5%,transparent)]",
"hover:[&>td]:bg-[color-mix(in_oklch,var(--color-surface-bright)_62%,transparent)]",
"hover:[&>td]:shadow-[inset_0_1px_0_color-mix(in_oklch,white_36%,transparent)]"
]
},
selected: {
false: "",
true: "bg-[color-mix(in_oklch,var(--color-primary)_8%,var(--ui-card-default-bg))]"
true:
[
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_64%,white_36%),color-mix(in_oklch,var(--color-primary)_8%,var(--ui-card-default-bg)))]",
"shadow-[inset_0_1px_0_color-mix(in_oklch,white_36%,transparent),0_14px_24px_color-mix(in_oklch,var(--color-primary)_7%,transparent)]",
"[&>td]:bg-[color-mix(in_oklch,var(--color-primary-container)_26%,transparent)]",
"[&>td]:shadow-[inset_0_1px_0_color-mix(in_oklch,white_40%,transparent)]"
]
}
},
defaultVariants: {
@@ -86,7 +115,7 @@ export const dataTableRowVariants = cva(
);
export const dataTableCellVariants = cva(
"px-4 text-[var(--color-card-foreground)]",
"px-4 first:pl-5 last:pr-5 align-top text-[var(--color-card-foreground)]",
{
variants: {
align: {
@@ -95,8 +124,8 @@ export const dataTableCellVariants = cva(
end: "text-right"
},
density: {
comfortable: "py-3 text-sm leading-6",
compact: "py-2.5 text-[0.8125rem] leading-5"
comfortable: "py-4 text-sm leading-6",
compact: "py-3 text-[0.8125rem] leading-5"
}
},
defaultVariants: {
@@ -107,19 +136,30 @@ export const dataTableCellVariants = cva(
);
export const dataTableSearchContainerVariants = cva(
"w-full max-w-[22rem] min-w-[14rem]"
"w-full max-w-[23.5rem] min-w-[15rem]"
);
export const dataTablePaginationVariants = cva(
"flex flex-wrap items-center justify-between gap-3 px-4 py-3"
[
"flex flex-wrap items-center justify-between gap-3 border-t px-5 py-3",
"border-[color-mix(in_oklch,var(--color-border)_72%,transparent)]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--ui-card-default-bg)_86%,white_14%),color-mix(in_oklch,var(--ui-card-subtle-bg)_78%,white_22%))]",
"transition-[border-color,background-color,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"[&>*]:transition-[background-color,border-color,box-shadow,opacity] [&>*]:duration-[var(--dur-base)] [&>*]:ease-[var(--ease-standard)]",
"motion-reduce:[&>*]:transition-none"
]
);
export const dataTableSelectionBarVariants = cva(
[
"flex flex-wrap items-center justify-between gap-3 rounded-[var(--ui-card-radius)]",
"border border-[color-mix(in_oklch,var(--color-primary)_24%,var(--ui-card-default-border))] [border-width:var(--ui-card-border-width)]",
"bg-[color-mix(in_oklch,var(--color-primary)_7%,var(--ui-card-default-bg))] px-4 py-3",
"shadow-[var(--ui-card-subtle-shadow)]"
"flex flex-wrap items-center justify-between gap-3 rounded-[calc(var(--ui-card-radius)-0.2rem)]",
"border border-[color-mix(in_oklch,var(--color-primary)_26%,var(--ui-card-default-border))] [border-width:var(--ui-card-border-width)]",
"bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-primary-container)_70%,white_30%),color-mix(in_oklch,var(--color-primary)_6%,var(--ui-card-default-bg)))] px-4 py-3.5",
"shadow-[var(--ui-card-subtle-shadow)]",
"motion-enter-fade motion-enter-rise",
"transition-[border-color,background-color,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-standard)]",
"[&>*]:transition-opacity [&>*]:duration-[var(--dur-fast)] [&>*]:ease-[var(--ease-standard)]",
"motion-reduce:[&>*]:transition-none"
]
);
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Button } from "./button";
@@ -44,6 +44,7 @@ describe("EmptyState", () => {
it("supports className overrides on sub-slots", () => {
render(
<EmptyState data-testid="empty-state" tone="subtle">
<EmptyStateMedia className="justify-items-start">A1</EmptyStateMedia>
<EmptyStateHeader className="items-start text-left">
<EmptyStateTitle className="text-left">No saved views</EmptyStateTitle>
</EmptyStateHeader>
@@ -51,6 +52,7 @@ describe("EmptyState", () => {
);
expect(screen.getByTestId("empty-state")).toHaveAttribute("data-tone", "subtle");
expect(screen.getByText("A1").closest('[data-slot="media"]')).toHaveClass("justify-items-start");
expect(screen.getByText("No saved views")).toHaveClass("text-left");
expect(screen.getByText("No saved views").closest('[data-slot="header"]')).toHaveClass(
"items-start"
@@ -104,4 +106,59 @@ describe("EmptyState", () => {
expect(screen.getByText("Workspace")).toHaveAttribute("data-slot", "eyebrow");
expect(screen.getByText("42")).toHaveAttribute("data-size", "hero");
});
it("adds subtle motion hooks by default and disables them in static motion mode", async () => {
const originalMotionMode = document.documentElement.dataset.motion;
try {
const { rerender } = render(
<EmptyState>
<EmptyStateMedia data-testid="default-media">A</EmptyStateMedia>
<EmptyStateHeader>
<EmptyStateTitle>No matching releases</EmptyStateTitle>
</EmptyStateHeader>
<EmptyStateActions>
<Button size="sm">Create release</Button>
<Button size="sm" variant="ghost">
Reset filters
</Button>
</EmptyStateActions>
</EmptyState>
);
expect(screen.getByTestId("default-media")).toHaveClass("motion-breathe");
expect(screen.getByRole("button", { name: "Create release" })).toHaveClass(
"[animation:aiui-slide-up-sm_var(--dur-base)_var(--ease-emphasized)_both]"
);
document.documentElement.dataset.motion = "static";
rerender(
<EmptyState>
<EmptyStateMedia data-testid="default-media">A</EmptyStateMedia>
<EmptyStateHeader>
<EmptyStateTitle>No matching releases</EmptyStateTitle>
</EmptyStateHeader>
<EmptyStateActions>
<Button size="sm">Create release</Button>
<Button size="sm" variant="ghost">
Reset filters
</Button>
</EmptyStateActions>
</EmptyState>
);
await waitFor(() => {
expect(screen.getByTestId("default-media")).not.toHaveClass("motion-breathe");
expect(screen.getByRole("button", { name: "Create release" })).not.toHaveClass(
"[animation:aiui-slide-up-sm_var(--dur-base)_var(--ease-emphasized)_both]"
);
});
} finally {
if (originalMotionMode) {
document.documentElement.dataset.motion = originalMotionMode;
} else {
delete document.documentElement.dataset.motion;
}
}
});
});
+82 -4
View File
@@ -1,4 +1,14 @@
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import { useReducedMotion } from "motion/react";
import {
Children,
cloneElement,
forwardRef,
isValidElement,
useEffect,
useState,
type ComponentPropsWithoutRef,
type CSSProperties
} from "react";
import {
emptyStateActionsVariants,
@@ -13,6 +23,40 @@ import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
function getIsStaticMotion() {
if (typeof document === "undefined") {
return false;
}
return document.documentElement.dataset.motion === "static";
}
function useMotionDisabled() {
const prefersReducedMotion = useReducedMotion();
const [isStaticMotion, setIsStaticMotion] = useState(getIsStaticMotion);
useEffect(() => {
if (typeof document === "undefined") {
return;
}
const syncMotionMode = () => {
setIsStaticMotion(getIsStaticMotion());
};
syncMotionMode();
const observer = new MutationObserver(syncMotionMode);
observer.observe(document.documentElement, {
attributeFilter: ["data-motion"]
});
return () => observer.disconnect();
}, []);
return prefersReducedMotion || isStaticMotion;
}
export type EmptyStateProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof emptyStateVariants>;
@@ -36,12 +80,20 @@ export type EmptyStateMediaProps = ComponentPropsWithoutRef<"div"> &
export const EmptyStateMedia = forwardRef<HTMLDivElement, EmptyStateMediaProps>(
function EmptyStateMedia({ className, size, ...props }, ref) {
const disableMotion = useMotionDisabled();
const ambientMotionClassName =
disableMotion || size === "compact"
? undefined
: size === "hero"
? "motion-float-delayed will-change-transform"
: "motion-breathe will-change-transform";
return (
<div
{...props}
{...createSlot("media")}
{...createDataAttributes({ size })}
className={cn(emptyStateMediaVariants({ size }), className)}
className={cn(emptyStateMediaVariants({ size }), ambientMotionClassName, className)}
ref={ref}
/>
);
@@ -115,7 +167,31 @@ export type EmptyStateActionsProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof emptyStateActionsVariants>;
export const EmptyStateActions = forwardRef<HTMLDivElement, EmptyStateActionsProps>(
function EmptyStateActions({ className, layout, ...props }, ref) {
function EmptyStateActions({ children, className, layout, ...props }, ref) {
const disableMotion = useMotionDisabled();
const animatedChildren = disableMotion
? children
: Children.map(children, (child, index) => {
if (
!isValidElement<{ className?: string; style?: CSSProperties }>(child) ||
typeof child.type === "symbol"
) {
return child;
}
return cloneElement(child, {
className: cn(
"[animation:aiui-slide-up-sm_var(--dur-base)_var(--ease-emphasized)_both]",
"will-change-transform",
child.props.className
),
style: {
...(child.props.style ?? {}),
animationDelay: `${Math.min(index * 70, 140)}ms`
}
});
});
return (
<div
{...props}
@@ -123,7 +199,9 @@ export const EmptyStateActions = forwardRef<HTMLDivElement, EmptyStateActionsPro
{...createDataAttributes({ layout })}
className={cn(emptyStateActionsVariants({ layout }), className)}
ref={ref}
/>
>
{animatedChildren}
</div>
);
}
);
@@ -3,8 +3,9 @@ import { getMotionRecipeClassNames } from "../lib/motion";
export const emptyStateVariants = cva(
[
"grid gap-6 rounded-[var(--ui-card-radius)] border shadow-[var(--ui-card-default-shadow)] [border-width:var(--ui-card-border-width)]",
"relative isolate grid gap-6 overflow-hidden rounded-[var(--ui-card-radius)] border shadow-[var(--ui-card-default-shadow)] [border-width:var(--ui-card-border-width)]",
"text-[var(--color-card-foreground)]",
"[&>[data-slot=actions]]:relative [&>[data-slot=actions]]:z-[1] [&>[data-slot=header]]:relative [&>[data-slot=header]]:z-[1] [&>[data-slot=media]]:relative [&>[data-slot=media]]:z-[1]",
getMotionRecipeClassNames("transition", "ring")
],
{
@@ -19,9 +20,19 @@ export const emptyStateVariants = cva(
layout: {
default: "p-8 sm:p-10",
compact:
"gap-4 p-6 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center sm:[&>[data-slot=media]]:row-span-2 sm:[&>[data-slot=header]]:justify-items-start sm:[&>[data-slot=actions]]:col-start-2 sm:[&>[data-slot=actions]]:justify-start",
"gap-4 p-6 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-center sm:[&>[data-slot=media]]:row-span-2 sm:[&>[data-slot=media]]:justify-self-start sm:[&>[data-slot=header]]:justify-items-start sm:[&>[data-slot=actions]]:col-start-2 sm:[&>[data-slot=actions]]:justify-start",
split:
"p-6 sm:p-8 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center lg:[&>[data-slot=media]]:col-start-2 lg:[&>[data-slot=media]]:row-span-2 lg:[&>[data-slot=media]]:justify-self-end lg:[&>[data-slot=header]]:col-start-1 lg:[&>[data-slot=actions]]:col-start-1 lg:[&>[data-slot=actions]]:justify-start"
[
"gap-5 p-6 sm:gap-6 sm:p-8",
"lg:grid-cols-[minmax(0,1.06fr)_minmax(15rem,0.84fr)] lg:gap-x-8 lg:gap-y-5 lg:items-center",
"lg:[&>[data-slot=media]]:col-start-2 lg:[&>[data-slot=media]]:row-span-2 lg:[&>[data-slot=media]]:justify-self-stretch lg:[&>[data-slot=media]]:self-stretch",
"lg:[&>[data-slot=header]]:col-start-1 lg:[&>[data-slot=header]]:max-w-[36rem] lg:[&>[data-slot=header]]:gap-3",
"lg:[&>[data-slot=actions]]:col-start-1 lg:[&>[data-slot=actions]]:justify-start lg:[&>[data-slot=actions]]:pt-1",
"lg:[&>[data-slot=header]_[data-slot=label]]:text-[clamp(2rem,3vw,3rem)] lg:[&>[data-slot=header]_[data-slot=label]]:leading-[1.08]",
"lg:[&>[data-slot=header]_[data-slot=description]]:max-w-[32rem] lg:[&>[data-slot=header]_[data-slot=description]]:text-[0.96rem] lg:[&>[data-slot=header]_[data-slot=description]]:leading-7",
"before:pointer-events-none before:absolute before:right-[-4rem] before:top-[-5rem] before:size-[16rem] before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-primary-container)_58%,white_42%)_0%,transparent_72%)] before:opacity-75 before:content-['']",
"after:pointer-events-none after:absolute after:bottom-[-7rem] after:right-[18%] after:size-[14rem] after:rounded-full after:bg-[radial-gradient(circle,color-mix(in_oklch,var(--color-tertiary-container)_54%,white_46%)_0%,transparent_74%)] after:opacity-65 after:content-['']"
]
},
align: {
center: "justify-items-center text-center",
@@ -38,16 +49,19 @@ export const emptyStateVariants = cva(
export const emptyStateMediaVariants = cva(
[
"grid place-items-center rounded-[var(--ui-card-radius)] border [border-width:var(--ui-card-border-width)]",
"relative isolate grid place-items-center overflow-hidden rounded-[calc(var(--ui-card-radius)-0.125rem)] border [border-width:var(--ui-card-border-width)]",
"border-[var(--ui-card-subtle-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-background)_72%,white_28%),var(--ui-card-subtle-bg))]",
"text-[var(--color-foreground)] shadow-[var(--ui-card-subtle-shadow)]"
"text-[var(--color-foreground)] shadow-[var(--ui-card-subtle-shadow)]",
"before:pointer-events-none before:absolute before:inset-x-[14%] before:top-0 before:h-[52%] before:rounded-full before:bg-[linear-gradient(180deg,color-mix(in_oklch,white_70%,transparent),transparent)] before:opacity-90 before:blur-2xl before:content-['']",
"after:pointer-events-none after:absolute after:inset-3 after:rounded-[calc(var(--ui-card-radius)-0.875rem)] after:border after:border-[color-mix(in_oklch,var(--color-border)_72%,transparent)] after:opacity-55 after:content-['']",
"[&>*]:relative [&>*]:z-[1]"
],
{
variants: {
size: {
compact: "min-h-16 min-w-16 p-3",
default: "min-h-20 min-w-20 p-4",
hero: "min-h-28 min-w-28 p-6"
hero: "min-h-32 min-w-[10.5rem] p-5 sm:min-h-36 sm:min-w-[12rem] sm:p-6"
}
},
defaultVariants: {
@@ -56,7 +70,7 @@ export const emptyStateMediaVariants = cva(
}
);
export const emptyStateHeaderVariants = cva("grid max-w-[34rem] gap-2", {
export const emptyStateHeaderVariants = cva("grid max-w-[34rem] gap-2.5", {
variants: {
align: {
center: "justify-items-center text-center",
@@ -69,15 +83,15 @@ export const emptyStateHeaderVariants = cva("grid max-w-[34rem] gap-2", {
});
export const emptyStateEyebrowVariants = cva(
"text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
"inline-flex w-fit items-center rounded-full border border-[color-mix(in_oklch,var(--color-border)_68%,transparent)] bg-[color-mix(in_oklch,var(--color-surface-container-low)_70%,white_30%)] px-3 py-1 text-[0.72rem] font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)] shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]"
);
export const emptyStateTitleVariants = cva(
"text-2xl font-semibold tracking-[var(--tracking-tight)] text-[var(--color-foreground)]"
"text-[clamp(1.7rem,3vw,2.4rem)] font-semibold leading-[1.12] tracking-[var(--tracking-tight)] text-[var(--color-foreground)]"
);
export const emptyStateDescriptionVariants = cva(
"max-w-[30rem] text-sm leading-6 text-[var(--color-muted-foreground)]"
"max-w-[30rem] text-[0.95rem] leading-7 text-[var(--color-muted-foreground)]"
);
export const emptyStateActionsVariants = cva("flex flex-wrap items-center gap-3", {
+135
View File
@@ -0,0 +1,135 @@
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Gauge } from "./gauge";
const STARTUP_ANIMATION_DURATION = 520;
describe("Gauge", () => {
beforeEach(() => {
document.documentElement.dataset.motion = "static";
});
afterEach(() => {
delete document.documentElement.dataset.motion;
vi.restoreAllMocks();
vi.useRealTimers();
});
it("renders a dial meter with the expected slots and aria contract", () => {
render(
<Gauge
description="Lead scoring remains inside the healthy forecast band."
label="Forecast confidence"
value={72}
/>
);
const gauge = screen.getByRole("meter", { name: "Forecast confidence" });
const canvas = gauge.querySelector('[data-slot="canvas"]');
const indicator = gauge.querySelector('[data-slot="indicator"]');
const value = gauge.querySelector('[data-slot="value"]');
const ticks = gauge.querySelectorAll('[data-slot="tick"]');
expect(gauge).toHaveAttribute("data-slot", "root");
expect(gauge).toHaveAttribute("data-shape", "dial");
expect(gauge).toHaveAttribute("data-state", "value");
expect(gauge).toHaveAttribute("aria-valuemin", "0");
expect(gauge).toHaveAttribute("aria-valuemax", "100");
expect(gauge).toHaveAttribute("aria-valuenow", "72");
expect(canvas).toHaveAttribute("data-slot", "canvas");
expect(indicator).toHaveAttribute("data-variant", "default");
expect(indicator).toHaveAttribute("stroke-dasharray", "72 100");
expect(value).toHaveTextContent("72%");
expect(ticks).toHaveLength(0);
});
it("supports semi-circle geometry, semantic variants, and tick activation", () => {
render(
<Gauge
label="Capacity utilization"
max={80}
shape="semi"
tickCount={7}
tone="accent"
value={40}
variant="warning"
/>
);
const gauge = screen.getByRole("meter", { name: "Capacity utilization" });
const svg = gauge.querySelector('[data-slot="svg"]');
const indicator = gauge.querySelector('[data-slot="indicator"]');
const activeTicks = gauge.querySelectorAll('[data-slot="tick"][data-active]');
expect(gauge).toHaveAttribute("data-shape", "semi");
expect(gauge).toHaveAttribute("data-tone", "accent");
expect(gauge).toHaveAttribute("data-variant", "warning");
expect(gauge).toHaveAttribute("aria-valuemax", "80");
expect(gauge).toHaveAttribute("aria-valuenow", "40");
expect(svg).toHaveAttribute("viewBox", "0 0 120 76");
expect(indicator).toHaveAttribute("stroke-dasharray", "50 100");
expect(activeTicks).toHaveLength(4);
});
it("renders an empty state when no numeric value is available", () => {
render(<Gauge aria-label="Pipeline health" tickCount={0} value={null} />);
const gauge = screen.getByRole("meter", { name: "Pipeline health" });
const indicator = gauge.querySelector('[data-slot="indicator"]');
const ticks = gauge.querySelectorAll('[data-slot="tick"]');
expect(gauge).toHaveAttribute("data-state", "empty");
expect(gauge).not.toHaveAttribute("aria-valuenow");
expect(gauge.querySelector('[data-slot="value"]')).toHaveTextContent("—");
expect(indicator).toHaveAttribute("data-state", "empty");
expect(indicator).toHaveAttribute("stroke-dasharray", "0 100");
expect(ticks).toHaveLength(0);
});
it("clamps values beyond the provided range and marks the max state", () => {
render(<Gauge aria-label="Budget saturation" max={120} value={160} />);
const gauge = screen.getByRole("meter", { name: "Budget saturation" });
const indicator = gauge.querySelector('[data-slot="indicator"]');
expect(gauge).toHaveAttribute("data-state", "max");
expect(gauge).toHaveAttribute("aria-valuenow", "120");
expect(indicator).toHaveAttribute("stroke-dasharray", "100 100");
expect(gauge.querySelector('[data-slot="value"]')).toHaveTextContent("120");
});
it("stages the sweep and value count-up when motion is enabled", () => {
delete document.documentElement.dataset.motion;
vi.useFakeTimers();
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
return window.setTimeout(() => callback(performance.now()), 16);
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((handle) => {
window.clearTimeout(handle);
});
render(<Gauge label="Forecast confidence" value={72} />);
const gauge = screen.getByRole("meter", { name: "Forecast confidence" });
const indicator = gauge.querySelector('[data-slot="indicator"]');
const value = gauge.querySelector('[data-slot="value"]');
act(() => {
vi.advanceTimersByTime(16);
});
expect(gauge).toHaveAttribute("data-animating", "");
expect(indicator).toHaveAttribute("stroke-dasharray", "0 100");
expect(value).toHaveTextContent("0%");
act(() => {
vi.advanceTimersByTime(STARTUP_ANIMATION_DURATION + 80);
});
expect(gauge).not.toHaveAttribute("data-animating");
expect(indicator).toHaveAttribute("stroke-dasharray", "72 100");
expect(value).toHaveTextContent("72%");
});
});
+605
View File
@@ -0,0 +1,605 @@
import {
forwardRef,
useEffect,
useId,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode
} from "react";
import {
gaugeCanvasVariants,
gaugeDescriptionVariants,
gaugeIndicatorVariants,
gaugeLabelVariants,
gaugeSvgVariants,
gaugeTickVariants,
gaugeTicksVariants,
gaugeTrackVariants,
gaugeValueVariants,
gaugeVariants
} from "./gauge.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
const DEFAULT_MAX = 100;
const DEFAULT_MIN = 0;
const DEFAULT_TICK_COUNTS = {
dial: 0,
semi: 0
} as const;
const MIN_TICK_COUNT = 0;
const MAX_TICK_COUNT = 24;
const numberFormatter = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 1
});
const INITIAL_SWEEP_DURATION = 520;
const UPDATE_SWEEP_DURATION = 320;
type GaugeShape = "dial" | "semi";
type GaugeGeometry = {
centerX: number;
centerY: number;
endAngle: number;
radius: number;
startAngle: number;
tickInnerRadius: number;
tickOuterRadius: number;
viewBox: string;
};
const GAUGE_GEOMETRY: Record<GaugeShape, GaugeGeometry> = {
dial: {
centerX: 60,
centerY: 60,
endAngle: 495,
radius: 42,
startAngle: 225,
tickInnerRadius: 47,
tickOuterRadius: 54,
viewBox: "0 0 120 120"
},
semi: {
centerX: 60,
centerY: 60,
endAngle: 450,
radius: 42,
startAngle: 270,
tickInnerRadius: 47,
tickOuterRadius: 54,
viewBox: "0 0 120 76"
}
};
export type GaugeValueFormatterContext = {
max: number;
min: number;
percentage: number | null;
value: number | null;
};
function normalizeNumber(value: number | null | undefined) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
return value;
}
function getResolvedMinMax(min: number | undefined, max: number | undefined) {
const resolvedMin = Number.isFinite(min) ? (min as number) : DEFAULT_MIN;
const resolvedMaxCandidate = Number.isFinite(max) ? (max as number) : DEFAULT_MAX;
const resolvedMax =
resolvedMaxCandidate > resolvedMin ? resolvedMaxCandidate : resolvedMin + 1;
return {
max: resolvedMax,
min: resolvedMin
};
}
function clampValue(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function getState(value: number | null | undefined, min: number, max: number) {
const normalizedValue = normalizeNumber(value);
if (normalizedValue == null) {
return "empty";
}
return clampValue(normalizedValue, min, max) >= max ? "max" : "value";
}
function getPercentage(value: number | null | undefined, min: number, max: number) {
const normalizedValue = normalizeNumber(value);
if (normalizedValue == null) {
return null;
}
return ((clampValue(normalizedValue, min, max) - min) / (max - min)) * 100;
}
function getResolvedTickCount(shape: GaugeShape, tickCount: number | undefined) {
if (!Number.isFinite(tickCount)) {
return DEFAULT_TICK_COUNTS[shape];
}
return Math.min(Math.max(Math.round(tickCount as number), MIN_TICK_COUNT), MAX_TICK_COUNT);
}
function polarToCartesian(
centerX: number,
centerY: number,
radius: number,
angleInDegrees: number
) {
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180;
return {
x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians)
};
}
function describeArc(
centerX: number,
centerY: number,
radius: number,
startAngle: number,
endAngle: number
) {
const start = polarToCartesian(centerX, centerY, radius, endAngle);
const end = polarToCartesian(centerX, centerY, radius, startAngle);
const sweep = Math.abs(endAngle - startAngle);
const largeArcFlag = sweep <= 180 ? "0" : "1";
return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 0 ${end.x} ${end.y}`;
}
function describeTick(
centerX: number,
centerY: number,
innerRadius: number,
outerRadius: number,
angle: number
) {
const start = polarToCartesian(centerX, centerY, outerRadius, angle);
const end = polarToCartesian(centerX, centerY, innerRadius, angle);
return `M ${start.x} ${start.y} L ${end.x} ${end.y}`;
}
function getActiveTickCount(percentage: number | null, tickCount: number) {
if (percentage == null || percentage <= 0 || tickCount <= 0) {
return 0;
}
if (percentage >= 100) {
return tickCount;
}
return Math.max(1, Math.round((tickCount - 1) * (percentage / 100)) + 1);
}
function defaultValueFormatter({
max,
min,
percentage,
value
}: GaugeValueFormatterContext) {
if (value == null || percentage == null) {
return "—";
}
const clampedValue = clampValue(value, min, max);
if (min === 0 && max === 100) {
return `${Math.round(clampedValue)}%`;
}
return numberFormatter.format(clampedValue);
}
function easeOutCubic(value: number) {
return 1 - (1 - value) ** 3;
}
function motionIsDisabled() {
if (typeof document !== "undefined" && document.documentElement.dataset.motion === "static") {
return true;
}
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
return false;
}
type LegacyMediaQueryList = MediaQueryList & {
addListener?: (listener: (event: MediaQueryListEvent) => void) => void;
removeListener?: (listener: (event: MediaQueryListEvent) => void) => void;
};
export type GaugeProps = Omit<ComponentPropsWithoutRef<"div">, "children"> &
VariantProps<typeof gaugeVariants> & {
description?: ReactNode;
label?: ReactNode;
max?: number;
min?: number;
tickCount?: number;
value?: number | null;
valueFormatter?: (context: GaugeValueFormatterContext) => ReactNode;
};
export const Gauge = forwardRef<HTMLDivElement, GaugeProps>(function Gauge(
{
"aria-describedby": ariaDescribedBy,
"aria-label": ariaLabelProp,
className,
description,
label,
max,
min,
shape = "dial",
size,
tickCount,
tone,
value,
valueFormatter,
variant,
...props
},
ref
) {
const resolvedShape: GaugeShape = shape ?? "dial";
const resolvedSize = size ?? "md";
const resolvedTone = tone ?? "default";
const resolvedVariant = variant ?? "default";
const { max: resolvedMax, min: resolvedMin } = getResolvedMinMax(min, max);
const state = getState(value, resolvedMin, resolvedMax);
const percentage = getPercentage(value, resolvedMin, resolvedMax);
const resolvedTickCount = getResolvedTickCount(resolvedShape, tickCount);
const geometry = GAUGE_GEOMETRY[resolvedShape];
const normalizedValue = normalizeNumber(value);
const formattedValue = (valueFormatter ?? defaultValueFormatter)({
max: resolvedMax,
min: resolvedMin,
percentage,
value: normalizedValue
});
const gradientId = `gauge-gradient-${useId().replace(/:/g, "")}`;
const trackPath = describeArc(
geometry.centerX,
geometry.centerY,
geometry.radius,
geometry.startAngle,
geometry.endAngle
);
const ticks =
resolvedTickCount > 0
? Array.from({ length: resolvedTickCount }, (_, index) => {
const ratio =
resolvedTickCount === 1 ? 1 : index / (resolvedTickCount - 1);
const angle =
geometry.startAngle +
(geometry.endAngle - geometry.startAngle) * ratio;
return {
index,
path: describeTick(
geometry.centerX,
geometry.centerY,
geometry.tickInnerRadius,
geometry.tickOuterRadius,
angle
)
};
})
: [];
const computedAriaLabel =
ariaLabelProp ?? (typeof label === "string" ? label : undefined);
const computedAriaValueNow =
normalizedValue == null
? undefined
: clampValue(normalizedValue, resolvedMin, resolvedMax);
const computedAriaValueText =
typeof formattedValue === "string" || typeof formattedValue === "number"
? String(formattedValue)
: undefined;
const [disableMotion, setDisableMotion] = useState(motionIsDisabled);
const [displayPercentage, setDisplayPercentage] = useState(() =>
motionIsDisabled() ? percentage ?? 0 : 0
);
const [displayValue, setDisplayValue] = useState<number | null>(() =>
motionIsDisabled()
? computedAriaValueNow ?? null
: computedAriaValueNow == null
? null
: resolvedMin
);
const [isAnimating, setIsAnimating] = useState(false);
const hasAnimatedRef = useRef(false);
const previousPercentageRef = useRef(percentage ?? 0);
const previousValueRef = useRef<number | null>(computedAriaValueNow ?? null);
useEffect(() => {
if (typeof document === "undefined") {
return;
}
const syncMotionMode = () => {
setDisableMotion(motionIsDisabled());
};
syncMotionMode();
const observer = new MutationObserver(syncMotionMode);
observer.observe(document.documentElement, {
attributeFilter: ["data-motion"]
});
const mediaQuery: LegacyMediaQueryList | null =
typeof window !== "undefined" && typeof window.matchMedia === "function"
? window.matchMedia("(prefers-reduced-motion: reduce)")
: null;
if (mediaQuery) {
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", syncMotionMode);
} else {
mediaQuery.addListener?.(syncMotionMode);
}
}
return () => {
observer.disconnect();
if (mediaQuery) {
if (typeof mediaQuery.removeEventListener === "function") {
mediaQuery.removeEventListener("change", syncMotionMode);
} else {
mediaQuery.removeListener?.(syncMotionMode);
}
}
};
}, []);
useEffect(() => {
const targetPercentage = percentage ?? 0;
const targetValue = computedAriaValueNow ?? null;
let frame = 0;
if (
disableMotion ||
targetValue == null ||
percentage == null ||
(hasAnimatedRef.current &&
previousPercentageRef.current === targetPercentage &&
previousValueRef.current === targetValue)
) {
frame = window.requestAnimationFrame(() => {
setDisplayPercentage(targetPercentage);
setDisplayValue(targetValue);
setIsAnimating(false);
previousPercentageRef.current = targetPercentage;
previousValueRef.current = targetValue;
if (targetValue != null) {
hasAnimatedRef.current = true;
}
});
return () => {
window.cancelAnimationFrame(frame);
};
}
const fromPercentage = hasAnimatedRef.current ? previousPercentageRef.current : 0;
const fromValue = hasAnimatedRef.current
? previousValueRef.current ?? resolvedMin
: resolvedMin;
const duration = hasAnimatedRef.current ? UPDATE_SWEEP_DURATION : INITIAL_SWEEP_DURATION;
let startTime: number | null = null;
const tick = (timestamp: number) => {
if (startTime == null) {
startTime = timestamp;
}
const progress = Math.min((timestamp - startTime) / duration, 1);
const easedProgress = easeOutCubic(progress);
setIsAnimating(progress < 1);
setDisplayPercentage(fromPercentage + (targetPercentage - fromPercentage) * easedProgress);
setDisplayValue(fromValue + (targetValue - fromValue) * easedProgress);
if (progress < 1) {
frame = window.requestAnimationFrame(tick);
return;
}
setDisplayPercentage(targetPercentage);
setDisplayValue(targetValue);
setIsAnimating(false);
previousPercentageRef.current = targetPercentage;
previousValueRef.current = targetValue;
hasAnimatedRef.current = true;
};
frame = window.requestAnimationFrame(tick);
return () => {
window.cancelAnimationFrame(frame);
};
}, [computedAriaValueNow, disableMotion, percentage, resolvedMin]);
const renderedPercentage = disableMotion ? percentage ?? 0 : displayPercentage;
const renderedValue =
disableMotion || computedAriaValueNow == null ? computedAriaValueNow ?? null : displayValue;
const renderedActiveTickCount = getActiveTickCount(renderedPercentage, resolvedTickCount);
const renderedFormattedValue = (valueFormatter ?? defaultValueFormatter)({
max: resolvedMax,
min: resolvedMin,
percentage: computedAriaValueNow == null ? null : renderedPercentage,
value: renderedValue
});
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
shape: resolvedShape,
size: resolvedSize,
state,
animating: isAnimating,
ticks: resolvedTickCount,
tone: resolvedTone,
variant: resolvedVariant
})}
aria-describedby={ariaDescribedBy}
aria-label={computedAriaLabel}
aria-valuemax={resolvedMax}
aria-valuemin={resolvedMin}
aria-valuenow={computedAriaValueNow}
aria-valuetext={computedAriaValueText}
className={cn(
gaugeVariants({
shape: resolvedShape,
size: resolvedSize,
tone: resolvedTone,
variant: resolvedVariant
}),
className
)}
ref={ref}
role="meter"
>
<div
{...createSlot("canvas")}
{...createDataAttributes({
shape: resolvedShape,
size: resolvedSize,
state,
animating: isAnimating,
ticks: resolvedTickCount,
tone: resolvedTone,
variant: resolvedVariant
})}
className={cn(gaugeCanvasVariants({ shape: resolvedShape }))}
>
<svg
{...createSlot("svg")}
aria-hidden="true"
className={cn(gaugeSvgVariants())}
viewBox={geometry.viewBox}
>
<defs>
<linearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%">
<stop offset="0%" stopColor="var(--ui-gauge-indicator-start)" />
<stop offset="100%" stopColor="var(--ui-gauge-indicator-end)" />
</linearGradient>
</defs>
{ticks.length > 0 ? (
<g
{...createSlot("ticks")}
{...createDataAttributes({
shape: resolvedShape,
size: resolvedSize,
state,
ticks: resolvedTickCount
})}
className={cn(gaugeTicksVariants())}
>
{ticks.map((tick) => (
<path
key={tick.index}
{...createSlot("tick")}
{...createDataAttributes({
active: tick.index < renderedActiveTickCount,
shape: resolvedShape,
size: resolvedSize,
state,
variant: resolvedVariant
})}
className={cn(gaugeTickVariants())}
d={tick.path}
strokeLinecap="round"
strokeWidth="2.25"
/>
))}
</g>
) : null}
<path
{...createSlot("track")}
{...createDataAttributes({
shape: resolvedShape,
size: resolvedSize,
state,
animating: isAnimating
})}
className={cn(gaugeTrackVariants())}
d={trackPath}
strokeLinecap="round"
strokeWidth="10"
/>
<path
{...createSlot("indicator")}
{...createDataAttributes({
shape: resolvedShape,
size: resolvedSize,
state,
animating: isAnimating,
variant: resolvedVariant
})}
className={cn(gaugeIndicatorVariants())}
d={trackPath}
pathLength={100}
stroke={`url(#${gradientId})`}
strokeDasharray={`${renderedPercentage} 100`}
strokeLinecap="round"
strokeWidth="10"
/>
</svg>
<div
{...createSlot("value")}
{...createDataAttributes({
state,
animating: isAnimating
})}
className={cn(gaugeValueVariants())}
>
{renderedFormattedValue}
</div>
</div>
{label ? (
<p
{...createSlot("label")}
className={cn(gaugeLabelVariants())}
>
{label}
</p>
) : null}
{description ? (
<p
{...createSlot("description")}
className={cn(gaugeDescriptionVariants())}
>
{description}
</p>
) : null}
</div>
);
});
@@ -0,0 +1,96 @@
import { cva } from "../lib/cva";
export const gaugeVariants = cva("inline-grid justify-items-center text-center", {
variants: {
size: {
sm:
"gap-2.5 [--ui-gauge-dial-width:7.75rem] [--ui-gauge-semi-width:10rem] [--ui-gauge-value-font-size:1.55rem] [--ui-gauge-value-height:2.5rem] [--ui-gauge-value-padding-inline:0.8rem] [--ui-gauge-label-font-size:0.9rem] [--ui-gauge-description-font-size:0.78rem]",
md:
"gap-3 [--ui-gauge-dial-width:9.5rem] [--ui-gauge-semi-width:12rem] [--ui-gauge-value-font-size:1.95rem] [--ui-gauge-value-height:2.9rem] [--ui-gauge-value-padding-inline:1rem] [--ui-gauge-label-font-size:0.96rem] [--ui-gauge-description-font-size:0.84rem]",
lg:
"gap-3.5 [--ui-gauge-dial-width:12rem] [--ui-gauge-semi-width:15rem] [--ui-gauge-value-font-size:2.45rem] [--ui-gauge-value-height:3.5rem] [--ui-gauge-value-padding-inline:1.15rem] [--ui-gauge-label-font-size:1.05rem] [--ui-gauge-description-font-size:0.92rem]"
},
shape: {
dial: "w-[var(--ui-gauge-dial-width)] [--ui-gauge-value-bottom:18%]",
semi: "w-[var(--ui-gauge-semi-width)] [--ui-gauge-value-bottom:2%]"
},
tone: {
default:
"[--ui-gauge-center-bg:var(--ui-gauge-center-default-bg)] [--ui-gauge-center-shadow:var(--ui-gauge-center-default-shadow)]",
subtle:
"[--ui-gauge-center-bg:var(--ui-gauge-center-subtle-bg)] [--ui-gauge-center-shadow:var(--ui-gauge-center-subtle-shadow)]",
accent:
"[--ui-gauge-center-bg:var(--ui-gauge-center-accent-bg)] [--ui-gauge-center-shadow:var(--ui-gauge-center-accent-shadow)]"
},
variant: {
default:
"[--ui-gauge-indicator-start:var(--ui-gauge-indicator-default-start)] [--ui-gauge-indicator-end:var(--ui-gauge-indicator-default-end)] [--ui-gauge-active-tick-stroke:var(--ui-gauge-indicator-default-end)]",
success:
"[--ui-gauge-indicator-start:var(--ui-gauge-indicator-success-start)] [--ui-gauge-indicator-end:var(--ui-gauge-indicator-success-end)] [--ui-gauge-active-tick-stroke:var(--ui-gauge-indicator-success-end)]",
warning:
"[--ui-gauge-indicator-start:var(--ui-gauge-indicator-warning-start)] [--ui-gauge-indicator-end:var(--ui-gauge-indicator-warning-end)] [--ui-gauge-active-tick-stroke:var(--ui-gauge-indicator-warning-end)]",
destructive:
"[--ui-gauge-indicator-start:var(--ui-gauge-indicator-destructive-start)] [--ui-gauge-indicator-end:var(--ui-gauge-indicator-destructive-end)] [--ui-gauge-active-tick-stroke:var(--ui-gauge-indicator-destructive-end)]"
}
},
defaultVariants: {
shape: "dial",
size: "md",
tone: "default",
variant: "default"
}
});
export const gaugeCanvasVariants = cva(
[
"relative isolate w-full",
"before:pointer-events-none before:absolute before:inset-x-[20%] before:top-[12%] before:h-[38%] before:rounded-full before:bg-[radial-gradient(circle,color-mix(in_oklch,var(--ui-gauge-indicator-start)_26%,transparent),transparent_72%)] before:opacity-0 before:blur-xl before:content-['']",
"data-[animating]:before:opacity-100 data-[animating]:before:animate-[aiui-breathe_620ms_var(--ease-standard)_1]"
],
{
variants: {
shape: {
dial: "aspect-square",
semi: "aspect-[120/76]"
}
},
defaultVariants: {
shape: "dial"
}
}
);
export const gaugeSvgVariants = cva("h-full w-full overflow-visible");
export const gaugeTrackVariants = cva("fill-none stroke-[var(--ui-gauge-track-stroke)]");
export const gaugeIndicatorVariants = cva(
[
"fill-none transition-[stroke-dasharray,opacity,filter] duration-[var(--dur-slow)] ease-[var(--ease-emphasized)]",
"data-[state=empty]:opacity-0",
"data-[animating]:[filter:drop-shadow(0_0_0.9rem_color-mix(in_oklch,var(--ui-gauge-indicator-end)_18%,transparent))]"
]
);
export const gaugeTicksVariants = cva("fill-none");
export const gaugeTickVariants = cva(
"fill-none stroke-[var(--ui-gauge-tick-stroke)] opacity-60 transition-[stroke,opacity] duration-[var(--dur-base)] ease-[var(--ease-standard)] data-[active]:stroke-[var(--ui-gauge-active-tick-stroke)] data-[active]:opacity-100"
);
export const gaugeValueVariants = cva(
[
"absolute inset-x-[18%] bottom-[var(--ui-gauge-value-bottom)] inline-flex min-h-[var(--ui-gauge-value-height)] items-center justify-center rounded-[var(--radius-full)]",
"bg-[var(--ui-gauge-center-bg)] px-[var(--ui-gauge-value-padding-inline)] text-[var(--ui-gauge-value-font-size)] font-semibold leading-none tracking-[var(--tracking-tight)] text-[var(--color-foreground)]",
"shadow-[var(--ui-gauge-center-shadow)] transition-[transform,opacity,box-shadow] duration-[var(--dur-slow)] ease-[var(--ease-emphasized)] will-change-transform",
"data-[animating]:animate-[aiui-breathe_620ms_var(--ease-standard)_1]"
]
);
export const gaugeLabelVariants = cva(
"text-[var(--ui-gauge-label-font-size)] font-medium text-[var(--color-foreground)]"
);
export const gaugeDescriptionVariants = cva(
"max-w-[24rem] text-[var(--ui-gauge-description-font-size)] leading-6 text-[var(--color-muted-foreground)]"
);
+52
View File
@@ -0,0 +1,52 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Col, Row } from "./grid";
describe("Grid", () => {
it("renders row and item slots", () => {
render(
<Row data-testid="row">
<Col data-testid="col">Content</Col>
</Row>
);
expect(screen.getByTestId("row")).toHaveAttribute("data-slot", "root");
expect(screen.getByTestId("row")).toHaveAttribute("data-gap", "md");
expect(screen.getByTestId("col")).toHaveAttribute("data-slot", "item");
expect(screen.getByTestId("col")).toHaveAttribute("data-span", "full");
});
it("applies tokenized gap and alignment classes on the row", () => {
render(
<Row align="center" data-testid="row" gap="lg" xGap="xl" yGap="sm">
<Col>Metric</Col>
</Row>
);
expect(screen.getByTestId("row")).toHaveClass("items-center");
expect(screen.getByTestId("row")).toHaveClass("gap-[var(--ui-grid-gap-lg)]");
expect(screen.getByTestId("row")).toHaveClass("gap-x-[var(--ui-grid-gap-xl)]");
expect(screen.getByTestId("row")).toHaveClass("gap-y-[var(--ui-grid-gap-sm)]");
});
it("writes base and responsive placement variables on columns", () => {
render(
<Col
data-testid="col"
lg={{ offset: 2, span: 4 }}
offset={1}
span={6}
xxl="auto"
/>
);
const column = screen.getByTestId("col");
expect(column.style.getPropertyValue("--ui-col-placement")).toBe("2 / span 6");
expect(column.style.getPropertyValue("--ui-col-placement-lg")).toBe("3 / span 4");
expect(column.style.getPropertyValue("--ui-col-placement-2xl")).toBe("auto");
expect(column).toHaveAttribute("data-span", "6");
expect(column).toHaveAttribute("data-offset", "1");
});
});
+191
View File
@@ -0,0 +1,191 @@
import {
forwardRef,
type ComponentPropsWithoutRef,
type CSSProperties
} from "react";
import {
colVariants,
rowGapClasses,
rowVariants,
rowXGapClasses,
rowYGapClasses
} from "./grid.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type GridSpan = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | "auto" | "full";
export type GridOffset = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
export type GridResponsiveValue =
| GridSpan
| {
offset?: GridOffset;
span?: GridSpan;
};
type BreakpointKey = "sm" | "md" | "lg" | "xl" | "xxl";
type GridPlacement = {
offset?: GridOffset;
span?: GridSpan;
};
type GridStyleVarName =
| "--ui-col-placement"
| "--ui-col-placement-sm"
| "--ui-col-placement-md"
| "--ui-col-placement-lg"
| "--ui-col-placement-xl"
| "--ui-col-placement-2xl";
type GridStyle = CSSProperties &
Partial<Record<GridStyleVarName, string>>;
const breakpointVars: Record<BreakpointKey, GridStyleVarName> = {
sm: "--ui-col-placement-sm",
md: "--ui-col-placement-md",
lg: "--ui-col-placement-lg",
xl: "--ui-col-placement-xl",
xxl: "--ui-col-placement-2xl"
};
const breakpointKeys = Object.keys(breakpointVars) as BreakpointKey[];
function normalizePlacement(value?: GridResponsiveValue): GridPlacement | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value === "number" || typeof value === "string") {
return { span: value };
}
return value;
}
function resolvePlacementValue(placement?: GridPlacement) {
if (!placement) {
return undefined;
}
const offset = placement.offset ?? 0;
if (placement.span === undefined && offset === 0) {
return undefined;
}
const span = placement.span ?? "full";
if (span === "full") {
return offset > 0 ? `${offset + 1} / -1` : "1 / -1";
}
if (span === "auto") {
return offset > 0 ? `${offset + 1} / auto` : "auto";
}
return offset > 0 ? `${offset + 1} / span ${span}` : `span ${span} / span ${span}`;
}
export type RowGap = keyof typeof rowGapClasses;
export type RowProps = ComponentPropsWithoutRef<"div"> &
VariantProps<typeof rowVariants> & {
gap?: RowGap;
xGap?: RowGap;
yGap?: RowGap;
};
export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
{
align = "stretch",
className,
gap = "md",
xGap,
yGap,
...props
},
ref
) {
return (
<div
{...props}
{...createSlot("root")}
{...createDataAttributes({
align,
gap
})}
className={cn(
rowVariants({ align }),
rowGapClasses[gap],
xGap ? rowXGapClasses[xGap] : undefined,
yGap ? rowYGapClasses[yGap] : undefined,
className
)}
ref={ref}
/>
);
});
export type ColProps = ComponentPropsWithoutRef<"div"> & {
offset?: GridOffset;
span?: GridSpan;
sm?: GridResponsiveValue;
md?: GridResponsiveValue;
lg?: GridResponsiveValue;
xl?: GridResponsiveValue;
xxl?: GridResponsiveValue;
};
export const Col = forwardRef<HTMLDivElement, ColProps>(function Col(
{
className,
offset,
span,
style,
sm,
md,
lg,
xl,
xxl,
...props
},
ref
) {
const resolvedStyle: GridStyle = { ...(style ?? {}) };
const basePlacement = resolvePlacementValue({ offset, span });
if (basePlacement) {
resolvedStyle["--ui-col-placement"] = basePlacement;
}
const responsiveValues = { sm, md, lg, xl, xxl } satisfies Record<
BreakpointKey,
GridResponsiveValue | undefined
>;
for (const key of breakpointKeys) {
const placement = resolvePlacementValue(normalizePlacement(responsiveValues[key]));
if (placement) {
resolvedStyle[breakpointVars[key]] = placement;
}
}
return (
<div
{...props}
{...createSlot("item")}
{...createDataAttributes({
offset: offset && offset > 0 ? offset : undefined,
span: span ?? "full"
})}
className={cn(colVariants(), className)}
ref={ref}
style={resolvedStyle}
/>
);
});
@@ -0,0 +1,52 @@
import { cva } from "../lib/cva";
export const rowGapClasses = {
none: "gap-0",
xs: "gap-[var(--ui-grid-gap-xs)]",
sm: "gap-[var(--ui-grid-gap-sm)]",
md: "gap-[var(--ui-grid-gap-md)]",
lg: "gap-[var(--ui-grid-gap-lg)]",
xl: "gap-[var(--ui-grid-gap-xl)]"
} as const;
export const rowXGapClasses = {
none: "gap-x-0",
xs: "gap-x-[var(--ui-grid-gap-xs)]",
sm: "gap-x-[var(--ui-grid-gap-sm)]",
md: "gap-x-[var(--ui-grid-gap-md)]",
lg: "gap-x-[var(--ui-grid-gap-lg)]",
xl: "gap-x-[var(--ui-grid-gap-xl)]"
} as const;
export const rowYGapClasses = {
none: "gap-y-0",
xs: "gap-y-[var(--ui-grid-gap-xs)]",
sm: "gap-y-[var(--ui-grid-gap-sm)]",
md: "gap-y-[var(--ui-grid-gap-md)]",
lg: "gap-y-[var(--ui-grid-gap-lg)]",
xl: "gap-y-[var(--ui-grid-gap-xl)]"
} as const;
export const rowVariants = cva("grid w-full min-w-0 auto-rows-auto grid-cols-12", {
variants: {
align: {
start: "items-start",
center: "items-center",
end: "items-end",
stretch: "items-stretch"
}
},
defaultVariants: {
align: "stretch"
}
});
export const colVariants = cva([
"min-w-0",
"[grid-column:var(--ui-col-placement,1_/_-1)]",
"sm:[grid-column:var(--ui-col-placement-sm,var(--ui-col-placement,1_/_-1))]",
"md:[grid-column:var(--ui-col-placement-md,var(--ui-col-placement-sm,var(--ui-col-placement,1_/_-1)))]",
"lg:[grid-column:var(--ui-col-placement-lg,var(--ui-col-placement-md,var(--ui-col-placement-sm,var(--ui-col-placement,1_/_-1))))]",
"xl:[grid-column:var(--ui-col-placement-xl,var(--ui-col-placement-lg,var(--ui-col-placement-md,var(--ui-col-placement-sm,var(--ui-col-placement,1_/_-1)))))]",
"2xl:[grid-column:var(--ui-col-placement-2xl,var(--ui-col-placement-xl,var(--ui-col-placement-lg,var(--ui-col-placement-md,var(--ui-col-placement-sm,var(--ui-col-placement,1_/_-1))))))]"
]);
@@ -0,0 +1,68 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Field, FieldControl, FieldDescription, FieldError } from "./field";
import { InputGroup, InputGroupPrefix, InputGroupSuffix } from "./input-group";
import { Input } from "./input";
import { Label } from "./label";
describe("InputGroup", () => {
it("renders affix slots and propagates grouped state to the input", () => {
render(
<InputGroup disabled invalid readOnly required size="lg">
<InputGroupPrefix>
<span aria-hidden="true">#</span>
</InputGroupPrefix>
<Input aria-label="Search launches" />
<InputGroupSuffix>
<span>K</span>
</InputGroupSuffix>
</InputGroup>
);
const input = screen.getByRole("textbox", { name: "Search launches" });
const control = input.closest('[data-slot="control"]');
expect(control).toHaveAttribute("data-slot", "control");
expect(control).toHaveAttribute("data-disabled", "");
expect(control).toHaveAttribute("data-invalid", "");
expect(control).toHaveAttribute("data-readonly", "");
expect(control).toHaveAttribute("data-required", "");
expect(control).toHaveAttribute("data-size", "lg");
expect(input).toHaveAttribute("data-slot", "input");
expect(input).toHaveAttribute("data-size", "lg");
expect(input).toBeDisabled();
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toHaveAttribute("readonly");
expect(input).toBeRequired();
expect(screen.getByText("#").closest('[data-slot="prefix"]')).toBeInTheDocument();
expect(screen.getByText("⌘K").closest('[data-slot="suffix"]')).toBeInTheDocument();
});
it("keeps field ids and described-by wiring when grouped inside a field control", () => {
render(
<Field invalid required>
<Label requiredIndicator>Lane search</Label>
<FieldControl>
<InputGroup>
<InputGroupPrefix aria-hidden="true">
<span>#</span>
</InputGroupPrefix>
<Input />
</InputGroup>
<FieldDescription>Find the right routing lane before queuing.</FieldDescription>
<FieldError>Search query is required.</FieldError>
</FieldControl>
</Field>
);
const input = screen.getByRole("textbox", { name: "Lane search" });
const description = screen.getByText("Find the right routing lane before queuing.");
const error = screen.getByText("Search query is required.");
expect(input).toHaveAttribute("aria-describedby", `${description.id} ${error.id}`);
expect(input).toHaveAttribute("aria-invalid", "true");
expect(input).toBeRequired();
expect(input.closest('[data-slot="control"]')).toHaveAttribute("data-invalid", "");
});
});

Some files were not shown because too many files have changed in this diff Show More