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:
2026-04-09 09:56:59 +03:00
parent 9916ef5aaf
commit aefa1db2bd
20 changed files with 134 additions and 43 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ function getAbsolutePath(value: string) {
}
const config: StorybookConfig = {
"stories": [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
"./stories/**/*.mdx",
"./stories/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
getAbsolutePath('@storybook/addon-vitest'),
@@ -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 = {};