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
+7
View File
@@ -7,6 +7,13 @@
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.brand {
font-weight: 700;
font-size: 1rem;
+7 -2
View File
@@ -1,5 +1,5 @@
import styles from './App.module.css';
import { SidePanel } from './ui';
import { SidePanel, ThemeSwitcher } from './ui';
import { EnvironmentsPage } from './pages/EnvironmentsPage';
import { KeysPage } from './pages/KeysPage';
import { SessionsPage } from './pages/SessionsPage';
@@ -21,7 +21,12 @@ export default function App() {
return (
<div className={styles.shell}>
<SidePanel
header={<span className={styles.brand}>QA Bot</span>}
header={
<div className={styles.header}>
<span className={styles.brand}>QA Bot</span>
<ThemeSwitcher />
</div>
}
width={220}
>
<nav className={styles.nav}>
+26
View File
@@ -0,0 +1,26 @@
import { useState, useEffect } from 'react';
type Theme = 'light' | 'dark';
const STORAGE_KEY = 'qa-bot-theme';
function getInitialTheme(): Theme {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function useTheme() {
const [theme, setTheme] = useState<Theme>(getInitialTheme);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
}, [theme]);
function toggleTheme() {
setTheme(t => (t === 'light' ? 'dark' : 'light'));
}
return { theme, toggleTheme } as const;
}
-25
View File
@@ -1,25 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Badge } from './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 } };
@@ -1,48 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Breadcrumbs } from './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' },
],
},
};
-26
View File
@@ -1,26 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from './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 } };
-46
View File
@@ -1,46 +0,0 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Card } from './Card';
import { Badge } from '../Badge/Badge';
import { Button } from '../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>
),
};
-101
View File
@@ -1,101 +0,0 @@
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 /> };
-40
View File
@@ -1,40 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Input } from './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 },
};
-75
View File
@@ -1,75 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Select } from './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,
},
};
@@ -1,83 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { SidePanel } from './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 />,
},
};
export const DefaultCollapsed: Story = {
args: {
header: 'Navigation',
children: <NavList />,
defaultCollapsed: true,
},
};
export const NarrowWidth: Story = {
args: {
header: 'Filters',
width: 180,
children: <NavList />,
},
};
export const WideWidth: Story = {
args: {
header: 'Scenarios',
width: 320,
children: <NavList />,
},
};
-71
View File
@@ -1,71 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Table } from './Table';
import { Badge } from '../Badge/Badge';
const meta: Meta<typeof Table> = {
title: 'UI/Table',
component: Table,
tags: ['autodocs'],
parameters: { layout: 'padded' },
};
export default meta;
// ── Simple string rows ────────────────────────────────────────────────────────
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,26 @@
.btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: none;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-muted);
cursor: pointer;
flex-shrink: 0;
transition: background var(--transition), color var(--transition), border-color var(--transition);
}
.btn:hover {
background: var(--color-bg-subtle);
color: var(--color-text);
border-color: var(--color-text-muted);
}
.btn:active {
background: var(--color-secondary);
color: var(--color-secondary-fg);
}
@@ -0,0 +1,18 @@
import { Sun, Moon } from 'lucide-react';
import { useTheme } from '../../hooks/useTheme';
import styles from './ThemeSwitcher.module.css';
export function ThemeSwitcher() {
const { theme, toggleTheme } = useTheme();
return (
<button
className={styles.btn}
onClick={toggleTheme}
aria-label={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
title={theme === 'dark' ? 'Light mode' : 'Dark mode'}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
);
}
+2
View File
@@ -19,5 +19,7 @@ export type { SidePanelProps } from './SidePanel/SidePanel';
export { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';
export type { BreadcrumbsProps, BreadcrumbItem } from './Breadcrumbs/Breadcrumbs';
export { ThemeSwitcher } from './ThemeSwitcher/ThemeSwitcher';
export { Table } from './Table/Table';
export type { TableProps, TableColumn } from './Table/Table';