feat(client): add ThemeSwitcher and relocate stories to .storybook/
- add lucide-react for Sun/Moon icons - add useTheme hook persisting choice to localStorage with prefers-color-scheme fallback - add ThemeSwitcher button component wired into SidePanel header - add blocking inline script to index.html to prevent flash-of-wrong-theme - move all *.stories.tsx from src/ into .storybook/stories/ and update imports - update main.ts stories glob to .storybook/stories/**
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Badge } from '../../src/ui/Badge/Badge';
|
||||
|
||||
const meta: Meta<typeof Badge> = {
|
||||
title: 'UI/Badge',
|
||||
component: Badge,
|
||||
parameters: { layout: 'centered' },
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['success', 'error', 'warning', 'info', 'neutral', 'running'],
|
||||
},
|
||||
dot: { control: 'boolean' },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Badge>;
|
||||
|
||||
export const Success: Story = { args: { children: 'Passed', variant: 'success', dot: true } };
|
||||
export const Error: Story = { args: { children: 'Failed', variant: 'error', dot: true } };
|
||||
export const Warning: Story = { args: { children: 'Warning', variant: 'warning' } };
|
||||
export const Info: Story = { args: { children: 'Info', variant: 'info' } };
|
||||
export const Neutral: Story = { args: { children: 'Pending', variant: 'neutral' } };
|
||||
export const Running: Story = { args: { children: 'Running', variant: 'running', dot: true } };
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Breadcrumbs } from '../../src/ui/Breadcrumbs/Breadcrumbs';
|
||||
|
||||
const meta: Meta<typeof Breadcrumbs> = {
|
||||
title: 'UI/Breadcrumbs',
|
||||
component: Breadcrumbs,
|
||||
parameters: { layout: 'centered' },
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Breadcrumbs>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ label: 'Scenarios', href: '#' },
|
||||
{ label: 'Login flow' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Deep: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ label: 'Home', href: '#' },
|
||||
{ label: 'Scenarios', href: '#' },
|
||||
{ label: 'Login flow', href: '#' },
|
||||
{ label: 'Step 3' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleItem: Story = {
|
||||
args: {
|
||||
items: [{ label: 'Dashboard' }],
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomSeparator: Story = {
|
||||
args: {
|
||||
separator: '/',
|
||||
items: [
|
||||
{ label: 'Environments', href: '#' },
|
||||
{ label: 'Staging', href: '#' },
|
||||
{ label: 'Config' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Button } from '../../src/ui/Button/Button';
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: 'UI/Button',
|
||||
component: Button,
|
||||
parameters: { layout: 'centered' },
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: { control: 'select', options: ['primary', 'secondary', 'ghost', 'danger'] },
|
||||
size: { control: 'select', options: ['sm', 'md', 'lg'] },
|
||||
loading: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Button>;
|
||||
|
||||
export const Primary: Story = { args: { children: 'Button', variant: 'primary' } };
|
||||
export const Secondary: Story = { args: { children: 'Button', variant: 'secondary' } };
|
||||
export const Ghost: Story = { args: { children: 'Button', variant: 'ghost' } };
|
||||
export const Danger: Story = { args: { children: 'Delete', variant: 'danger' } };
|
||||
export const Small: Story = { args: { children: 'Small', size: 'sm' } };
|
||||
export const Large: Story = { args: { children: 'Large', size: 'lg' } };
|
||||
export const Loading: Story = { args: { children: 'Saving…', loading: true } };
|
||||
export const Disabled: Story = { args: { children: 'Button', disabled: true } };
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Card } from '../../src/ui/Card/Card';
|
||||
import { Badge } from '../../src/ui/Badge/Badge';
|
||||
import { Button } from '../../src/ui/Button/Button';
|
||||
|
||||
const meta: Meta<typeof Card> = {
|
||||
title: 'UI/Card',
|
||||
component: Card,
|
||||
parameters: { layout: 'centered' },
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: 360 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
argTypes: {
|
||||
padding: { control: 'select', options: ['sm', 'md', 'lg'] },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Card>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { children: 'Card content goes here.', padding: 'md' },
|
||||
};
|
||||
|
||||
export const ScenarioCard: Story = {
|
||||
render: () => (
|
||||
<Card>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>Login flow</span>
|
||||
<Badge variant="success" dot>Passed</Badge>
|
||||
</div>
|
||||
<p style={{ margin: '0 0 16px', fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||
Last run 2 minutes ago · 4 steps
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button size="sm" variant="primary">Run</Button>
|
||||
<Button size="sm" variant="ghost">Edit</Button>
|
||||
</div>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
const palette = [
|
||||
{ name: 'Atlantis', token: '--color-primary', hex: '#9DD341', role: 'Primary / Action' },
|
||||
{ name: 'Outer Space', token: '--color-text', hex: '#2D3339', role: 'Text / Surface' },
|
||||
{ name: 'Kimberly', token: '--color-text-muted', hex: '#6650C0', role: 'Muted text / Info' },
|
||||
{ name: 'Powder Ash', token: '--color-border', hex: '#AFBFB9', role: 'Border / Neutral' },
|
||||
];
|
||||
|
||||
const semanticColors = [
|
||||
{ name: 'Success bg', token: '--color-success-bg', hex: '#EEF8D6' },
|
||||
{ name: 'Success fg', token: '--color-success-fg', hex: '#3E6010' },
|
||||
{ name: 'Error bg', token: '--color-error-bg', hex: '#FAEAEA' },
|
||||
{ name: 'Error fg', token: '--color-error-fg', hex: '#8B2020' },
|
||||
{ name: 'Warning bg', token: '--color-warning-bg', hex: '#FEF5E0' },
|
||||
{ name: 'Warning fg', token: '--color-warning-fg', hex: '#7A4E00' },
|
||||
{ name: 'Info bg', token: '--color-info-bg', hex: '#EEEDF6' },
|
||||
{ name: 'Info fg', token: '--color-info-fg', hex: '#4A4270' },
|
||||
{ name: 'Neutral bg', token: '--color-neutral-bg', hex: '#EDF1EF' },
|
||||
{ name: 'Neutral fg', token: '--color-neutral-fg', hex: '#4D6059' },
|
||||
{ name: 'Running bg', token: '--color-running-bg', hex: '#FEF9C3' },
|
||||
{ name: 'Running fg', token: '--color-running-fg', hex: '#6B4A00' },
|
||||
];
|
||||
|
||||
const surfaceColors = [
|
||||
{ name: 'Background', token: '--color-bg', hex: '#E6EDEA' },
|
||||
{ name: 'Background subtle', token: '--color-bg-subtle', hex: '#D8E2DE' },
|
||||
{ name: 'Secondary', token: '--color-secondary', hex: '#EDF1EF' },
|
||||
{ name: 'Secondary hover', token: '--color-secondary-hover', hex: '#DFE6E3' },
|
||||
{ name: 'Danger', token: '--color-danger', hex: '#D94F4F' },
|
||||
{ name: 'Primary fg', token: '--color-primary-fg', hex: '#2D3339' },
|
||||
];
|
||||
|
||||
function Swatch({ name, token, hex, role }: { name: string; token: string; hex: string; role?: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 8,
|
||||
border: 'var(--border-width) solid var(--color-border)',
|
||||
minWidth: 160,
|
||||
flex: '1 1 160px',
|
||||
maxWidth: 220,
|
||||
}}>
|
||||
<div style={{
|
||||
background: `var(${token}, ${hex})`,
|
||||
height: 80,
|
||||
}} />
|
||||
<div style={{ padding: '10px 12px', background: 'var(--color-bg)' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--color-text)', marginBottom: 2 }}>{name}</div>
|
||||
{role && <div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>{role}</div>}
|
||||
<div style={{ fontSize: 11, fontFamily: 'monospace', color: 'var(--color-text-muted)' }}>{hex}</div>
|
||||
<div style={{ fontSize: 11, fontFamily: 'monospace', color: 'var(--color-text-muted)' }}>{token}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 40 }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-family)', color: 'var(--color-text)', fontSize: 14, fontWeight: 600, marginBottom: 16, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||
{title}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorPalette() {
|
||||
return (
|
||||
<div style={{ padding: 32, fontFamily: 'var(--font-family)' }}>
|
||||
<h1 style={{ color: 'var(--color-text)', fontSize: 22, fontWeight: 700, marginBottom: 8 }}>Color Palette</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)', fontSize: 14, marginBottom: 40 }}>
|
||||
All colors are exposed as CSS custom properties on <code>:root</code> via <code>tokens.css</code>.
|
||||
</p>
|
||||
<Section title="Brand">
|
||||
{palette.map(c => <Swatch key={c.token} {...c} />)}
|
||||
</Section>
|
||||
<Section title="Semantic">
|
||||
{semanticColors.map(c => <Swatch key={c.token} {...c} />)}
|
||||
</Section>
|
||||
<Section title="Surface & Action">
|
||||
{surfaceColors.map(c => <Swatch key={c.token} {...c} />)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Design Tokens/Colors',
|
||||
parameters: { layout: 'fullscreen' },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
export const All: Story = { render: () => <ColorPalette /> };
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Input } from '../../src/ui/Input/Input';
|
||||
|
||||
const meta: Meta<typeof Input> = {
|
||||
title: 'UI/Input',
|
||||
component: Input,
|
||||
parameters: { layout: 'centered' },
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: 320 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Input>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { label: 'Label', placeholder: 'Placeholder' },
|
||||
};
|
||||
export const WithHint: Story = {
|
||||
args: {
|
||||
label: 'Environment name',
|
||||
placeholder: 'e.g. staging',
|
||||
hint: 'Used to identify this environment in scenarios.',
|
||||
},
|
||||
};
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
label: 'URL',
|
||||
placeholder: 'https://...',
|
||||
defaultValue: 'not a url',
|
||||
error: 'Must be a valid URL.',
|
||||
},
|
||||
};
|
||||
export const Disabled: Story = {
|
||||
args: { label: 'Label', value: 'Some value', disabled: true, readOnly: true },
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Select } from '../../src/ui/Select/Select';
|
||||
|
||||
const ENVIRONMENTS = [
|
||||
{ value: 'dev', label: 'Development' },
|
||||
{ value: 'staging', label: 'Staging' },
|
||||
{ value: 'prod', label: 'Production' },
|
||||
];
|
||||
|
||||
const SCENARIO_TYPES = [
|
||||
{ value: 'smoke', label: 'Smoke test' },
|
||||
{ value: 'regression', label: 'Regression' },
|
||||
{ value: 'e2e', label: 'End-to-end' },
|
||||
{ value: 'perf', label: 'Performance', disabled: true },
|
||||
];
|
||||
|
||||
const meta: Meta<typeof Select> = {
|
||||
title: 'UI/Select',
|
||||
component: Select,
|
||||
parameters: { layout: 'centered' },
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: 320 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Select>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: 'Environment',
|
||||
options: ENVIRONMENTS,
|
||||
placeholder: 'Select an environment…',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
label: 'Environment',
|
||||
options: ENVIRONMENTS,
|
||||
defaultValue: 'staging',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHint: Story = {
|
||||
args: {
|
||||
label: 'Test type',
|
||||
options: SCENARIO_TYPES,
|
||||
placeholder: 'Choose a type…',
|
||||
hint: 'Performance tests require a dedicated runner.',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
label: 'Environment',
|
||||
options: ENVIRONMENTS,
|
||||
defaultValue: '',
|
||||
placeholder: 'Select an environment…',
|
||||
error: 'Please select an environment.',
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
label: 'Environment',
|
||||
options: ENVIRONMENTS,
|
||||
defaultValue: 'prod',
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { SidePanel } from '../../src/ui/SidePanel/SidePanel';
|
||||
|
||||
const NAV_ITEMS = ['Dashboard', 'Scenarios', 'Environments', 'Reports', 'Settings'];
|
||||
|
||||
function NavList() {
|
||||
return (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<li key={item}>
|
||||
<button
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: 'var(--color-text)',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-family)',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-secondary)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof SidePanel> = {
|
||||
title: 'UI/SidePanel',
|
||||
component: SidePanel,
|
||||
parameters: { layout: 'fullscreen' },
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ display: 'flex', height: 420, background: 'var(--color-bg)' }}>
|
||||
<Story />
|
||||
<div style={{ flex: 1, padding: 'var(--space-6)', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
|
||||
Main content area
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SidePanel>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
header: 'Navigation',
|
||||
children: <NavList />,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { Table } from '../../src/ui/Table/Table';
|
||||
import { Badge } from '../../src/ui/Badge/Badge';
|
||||
|
||||
const meta: Meta<typeof Table> = {
|
||||
title: 'UI/Table',
|
||||
component: Table,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
status: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
const users: User[] = [
|
||||
{ id: 1, name: 'Alice Müller', email: 'alice@example.com', status: 'active' },
|
||||
{ id: 2, name: 'Bob Nguyen', email: 'bob@example.com', status: 'inactive' },
|
||||
{ id: 3, name: 'Carol Santos', email: 'carol@example.com', status: 'active' },
|
||||
];
|
||||
|
||||
export const Default: StoryObj<typeof Table<User>> = {
|
||||
args: {
|
||||
data: users,
|
||||
rowKey: (u) => u.id,
|
||||
emptyMessage: 'No users found.',
|
||||
columns: [
|
||||
{ key: 'id', header: 'ID', render: (u) => u.id, width: 60 },
|
||||
{ key: 'name', header: 'Name', render: (u) => u.name },
|
||||
{ key: 'email', header: 'Email', render: (u) => u.email },
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: 110,
|
||||
render: (u) => (
|
||||
<Badge variant={u.status === 'active' ? 'success' : 'neutral'}>
|
||||
{u.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: StoryObj<typeof Table<User>> = {
|
||||
args: {
|
||||
...Default.args,
|
||||
loading: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: StoryObj<typeof Table<User>> = {
|
||||
args: {
|
||||
...Default.args,
|
||||
data: [],
|
||||
emptyMessage: 'No users found.',
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleRow: StoryObj<typeof Table<User>> = {
|
||||
args: {
|
||||
...Default.args,
|
||||
data: [users[0]],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { ThemeSwitcher } from '../../src/ui/ThemeSwitcher/ThemeSwitcher';
|
||||
|
||||
const meta: Meta<typeof ThemeSwitcher> = {
|
||||
title: 'UI/ThemeSwitcher',
|
||||
component: ThemeSwitcher,
|
||||
parameters: { layout: 'centered' },
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ThemeSwitcher>;
|
||||
|
||||
export const Default: Story = {};
|
||||
Reference in New Issue
Block a user