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'),
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Badge } from './Badge';
import { Badge } from '../../src/ui/Badge/Badge';
const meta: Meta<typeof Badge> = {
title: 'UI/Badge',
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Breadcrumbs } from './Breadcrumbs';
import { Breadcrumbs } from '../../src/ui/Breadcrumbs/Breadcrumbs';
const meta: Meta<typeof Breadcrumbs> = {
title: 'UI/Breadcrumbs',
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from './Button';
import { Button } from '../../src/ui/Button/Button';
const meta: Meta<typeof Button> = {
title: 'UI/Button',
@@ -1,8 +1,8 @@
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';
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',
@@ -32,7 +32,7 @@ const surfaceColors = [
{ name: 'Primary fg', token: '--color-primary-fg', hex: '#2D3339' },
];
function Swatch({ name, token, hex, role }: { name: string; token: string; hex: string; role?: string }) {;
function Swatch({ name, token, hex, role }: { name: string; token: string; hex: string; role?: string }) {
return (
<div style={{
display: 'flex',
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Input } from './Input';
import { Input } from '../../src/ui/Input/Input';
const meta: Meta<typeof Input> = {
title: 'UI/Input',
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Select } from './Select';
import { Select } from '../../src/ui/Select/Select';
const ENVIRONMENTS = [
{ value: 'dev', label: 'Development' },
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { SidePanel } from './SidePanel';
import { SidePanel } from '../../src/ui/SidePanel/SidePanel';
const NAV_ITEMS = ['Dashboard', 'Scenarios', 'Environments', 'Reports', 'Settings'];
@@ -57,27 +57,3 @@ export const Default: Story = {
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 />,
},
};
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Table } from './Table';
import { Badge } from '../Badge/Badge';
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',
@@ -10,8 +10,6 @@ const meta: Meta<typeof Table> = {
};
export default meta;
// ── Simple string rows ────────────────────────────────────────────────────────
interface User {
id: number;
name: string;
@@ -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 = {};
+9
View File
@@ -4,6 +4,15 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Liquio QA Bot</title>
<script>
(function () {
var stored = localStorage.getItem('qa-bot-theme');
var theme = stored === 'light' || stored === 'dark'
? stored
: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
<body>
<div id="root"></div>
+1
View File
@@ -11,6 +11,7 @@
"build-storybook": "storybook build"
},
"dependencies": {
"lucide-react": "^1.7.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
+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;
}
@@ -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';
+10
View File
@@ -16,6 +16,7 @@
"name": "liquio-qa-bot-client",
"version": "0.0.1",
"dependencies": {
"lucide-react": "^1.7.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
@@ -9798,6 +9799,15 @@
"node": "20 || >=22"
}
},
"node_modules/lucide-react": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
"integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",