feat(client): add UI kit, data layer, app shell, and Table component

- design token system (Atlantis, Kimberly, Powder Ash palette)
- Button, Badge, Input, Select, Card, SidePanel, Breadcrumbs components
- generic typed Table<T> component with loading/empty states
- API data layer: typed fetch client for environments, keys, sessions, scenarios
- Vite dev proxy targeting server on port 13000
- App shell with SidePanel nav and four entity pages (Environments, Keys, Sessions, Scenarios)
- Storybook config with dark/light theme toggle and a11y addon
This commit is contained in:
2026-04-09 00:11:55 +03:00
parent 5cc16725fb
commit 9916ef5aaf
47 changed files with 3910 additions and 10 deletions
+3 -1
View File
@@ -10,4 +10,6 @@ node_modules/
# build outputs
/dist
server/dist
client/dist
client/dist
*storybook.log
storybook-static
+26
View File
@@ -0,0 +1,26 @@
import type { StorybookConfig } from '@storybook/react-vite';
import { dirname } from "path"
import { fileURLToPath } from "url"
/**
* This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/
function getAbsolutePath(value: string) {
return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`)))
}
const config: StorybookConfig = {
"stories": [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
],
"addons": [
getAbsolutePath('@storybook/addon-vitest'),
getAbsolutePath('@storybook/addon-a11y'),
getAbsolutePath('@storybook/addon-docs')
],
"framework": getAbsolutePath('@storybook/react-vite')
};
export default config;
+45
View File
@@ -0,0 +1,45 @@
import React from 'react'
import type { Preview } from '@storybook/react-vite'
import '../src/ui/tokens.css'
const preview: Preview = {
globalTypes: {
theme: {
name: 'Theme',
description: 'Color theme',
defaultValue: 'light',
toolbar: {
icon: 'circlehollow',
items: [
{ value: 'light', icon: 'sun', title: 'Light' },
{ value: 'dark', icon: 'moon', title: 'Dark' },
],
dynamicTitle: true,
},
},
},
decorators: [
(Story, context) => {
const theme = (context.globals['theme'] as string) ?? 'light'
document.documentElement.setAttribute('data-theme', theme)
return (
<div style={{ background: 'var(--color-bg)', minHeight: '100vh', padding: 0 }}>
<Story />
</div>
)
},
],
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
test: 'todo',
},
},
}
export default preview
+13 -2
View File
@@ -6,17 +6,28 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@storybook/addon-a11y": "^10.3.5",
"@storybook/addon-docs": "^10.3.5",
"@storybook/addon-vitest": "^10.3.5",
"@storybook/react-vite": "^10.3.5",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"@vitest/browser-playwright": "^4.1.3",
"@vitest/coverage-v8": "^4.1.3",
"playwright": "^1.59.1",
"storybook": "^10.3.5",
"typescript": "~5.8.3",
"vite": "^6.3.1"
"vite": "^6.3.1",
"vitest": "^4.1.3"
}
}
+57
View File
@@ -0,0 +1,57 @@
.shell {
display: flex;
height: 100vh;
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family);
overflow: hidden;
}
.brand {
font-weight: 700;
font-size: 1rem;
color: var(--color-primary);
letter-spacing: 0.02em;
}
.nav {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-2) 0;
}
.navItem {
display: block;
width: 100%;
padding: var(--space-2) var(--space-3);
background: none;
border: none;
border-radius: var(--radius-md);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-family);
text-align: left;
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.navItem:hover {
background: var(--color-bg-subtle);
}
.navItemActive {
background: var(--color-secondary);
color: var(--color-secondary-fg);
font-weight: 600;
}
.navItemActive:hover {
background: var(--color-secondary-hover);
}
.main {
flex: 1;
overflow-y: auto;
padding: var(--space-6);
}
+47 -1
View File
@@ -1,3 +1,49 @@
import styles from './App.module.css';
import { SidePanel } from './ui';
import { EnvironmentsPage } from './pages/EnvironmentsPage';
import { KeysPage } from './pages/KeysPage';
import { SessionsPage } from './pages/SessionsPage';
import { ScenariosPage } from './pages/ScenariosPage';
import { useState } from 'react';
type Section = 'environments' | 'keys' | 'sessions' | 'scenarios';
const NAV: { id: Section; label: string }[] = [
{ id: 'environments', label: 'Environments' },
{ id: 'keys', label: 'Keys' },
{ id: 'sessions', label: 'Sessions' },
{ id: 'scenarios', label: 'Scenarios' },
];
export default function App() {
return <h1>Hello World</h1>
const [active, setActive] = useState<Section>('scenarios');
return (
<div className={styles.shell}>
<SidePanel
header={<span className={styles.brand}>QA Bot</span>}
width={220}
>
<nav className={styles.nav}>
{NAV.map(({ id, label }) => (
<button
key={id}
className={`${styles.navItem} ${active === id ? styles.navItemActive : ''}`}
onClick={() => setActive(id)}
>
{label}
</button>
))}
</nav>
</SidePanel>
<main className={styles.main}>
{active === 'environments' && <EnvironmentsPage />}
{active === 'keys' && <KeysPage />}
{active === 'sessions' && <SessionsPage />}
{active === 'scenarios' && <ScenariosPage />}
</main>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import type {
PaginatedResponse,
Environment,
KeysResponse,
Session,
Scenario,
ScenarioRun,
ScenarioStep,
} from './types';
// In dev, Vite proxies /environments /sessions /scenarios /keys to localhost:3000.
// In production (or when VITE_API_URL is set) we hit the configured origin directly.
const BASE_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? '';
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
headers: { 'Content-Type': 'application/json', ...init?.headers },
...init,
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`${res.status} ${text}`);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
// ── Environments ──────────────────────────────────────────────────────────────
export const environments = {
list(page = 1, limit = 50): Promise<PaginatedResponse<Environment>> {
return request(`/environments?page=${page}&limit=${limit}`);
},
get(id: number): Promise<Environment> {
return request(`/environments/${id}`);
},
create(name: string, urls: Environment['urls']): Promise<Environment> {
return request('/environments', {
method: 'POST',
body: JSON.stringify({ name, urls }),
});
},
update(id: number, patch: Partial<Pick<Environment, 'name' | 'urls'>>): Promise<Environment> {
return request(`/environments/${id}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
},
remove(id: number): Promise<void> {
return request(`/environments/${id}`, { method: 'DELETE' });
},
};
// ── Keys ──────────────────────────────────────────────────────────────────────
export const keys = {
list(): Promise<KeysResponse> {
return request('/keys');
},
};
// ── Sessions ──────────────────────────────────────────────────────────────────
export const sessions = {
list(page = 1, limit = 50): Promise<PaginatedResponse<Session>> {
return request(`/sessions?page=${page}&limit=${limit}`);
},
remove(id: number): Promise<void> {
return request(`/sessions/${id}`, { method: 'DELETE' });
},
};
// ── Scenarios ─────────────────────────────────────────────────────────────────
export const scenarios = {
list(page = 1, limit = 50): Promise<PaginatedResponse<Scenario>> {
return request(`/scenarios?page=${page}&limit=${limit}`);
},
get(id: number): Promise<Scenario & { steps: ScenarioStep[] }> {
return request(`/scenarios/${id}`);
},
create(name: string): Promise<Scenario> {
return request('/scenarios', {
method: 'POST',
body: JSON.stringify({ name }),
});
},
update(id: number, patch: Partial<Pick<Scenario, 'name'>>): Promise<Scenario> {
return request(`/scenarios/${id}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
},
remove(id: number): Promise<void> {
return request(`/scenarios/${id}`, { method: 'DELETE' });
},
run(id: number): Promise<ScenarioRun> {
return request(`/scenarios/${id}/run`, { method: 'POST' });
},
getRun(scenarioId: number, runId: number): Promise<ScenarioRun> {
return request(`/scenarios/${scenarioId}/run/${runId}`);
},
waitForRun(scenarioId: number, runId: number): Promise<ScenarioRun> {
return request(`/scenarios/${scenarioId}/run/${runId}/wait`, { method: 'POST' });
},
listRuns(scenarioId: number, page = 1, limit = 20): Promise<PaginatedResponse<ScenarioRun>> {
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
},
};
+2
View File
@@ -0,0 +1,2 @@
export * from './client';
export * from './types';
+79
View File
@@ -0,0 +1,79 @@
// ── Shared ────────────────────────────────────────────────────────────────────
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
limit: number;
}
// ── Environments ──────────────────────────────────────────────────────────────
export interface EnvironmentUrls {
id_url?: string;
cabinet_url?: string;
admin_url?: string;
[key: string]: string | undefined;
}
export interface Environment {
id: number;
name: string;
urls: EnvironmentUrls;
createdAt: string;
updatedAt: string;
}
// ── Keys ──────────────────────────────────────────────────────────────────────
export interface KeysResponse {
keys: string[];
}
// ── Sessions ──────────────────────────────────────────────────────────────────
export type SessionStatus = 'open' | 'closed';
export interface Session {
id: number;
sessionName: string;
token: string;
status: SessionStatus;
lastUsedAt: string | null;
createdAt: string;
updatedAt: string;
}
// ── Scenarios ─────────────────────────────────────────────────────────────────
export type StepType = 'login' | 'exec' | 'sign';
export interface ScenarioStep {
id: number;
scenarioId: number;
order: number;
type: StepType;
sessionName: string;
execCode: string | null;
validateCode: string | null;
createdAt: string;
updatedAt: string;
}
export interface Scenario {
id: number;
name: string;
steps?: ScenarioStep[];
createdAt: string;
updatedAt: string;
}
export type ScenarioRunStatus = 'pending' | 'running' | 'pass' | 'fail';
export interface ScenarioRun {
id: number;
scenarioId: number;
status: ScenarioRunStatus;
createdAt: string;
updatedAt: string;
}
+8
View File
@@ -0,0 +1,8 @@
declare module '*.css' {
const styles: Record<string, string>;
export default styles;
}
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
}
+1
View File
@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './ui/tokens.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { environments } from '../api';
import type { Environment } from '../api';
import { Table, type TableColumn } from '../ui';
import styles from './Page.module.css';
const columns: TableColumn<Environment>[] = [
{ key: 'id', header: 'ID', render: (e) => e.id, width: 60 },
{ key: 'name', header: 'Name', render: (e) => e.name },
{
key: 'urls',
header: 'URLs',
render: (e) =>
Object.entries(e.urls)
.filter(([, v]) => v)
.map(([k, v]) => `${k}: ${v}`)
.join(' · ') || '—',
},
{
key: 'updated',
header: 'Updated',
width: 120,
render: (e) => new Date(e.updatedAt).toLocaleDateString(),
},
];
export function EnvironmentsPage() {
const [items, setItems] = useState<Environment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
environments.list()
.then((res) => setItems(res.data))
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, []);
return (
<div>
<h1 className={styles.heading}>Environments</h1>
{error && <p className={styles.error}>{error}</p>}
<Table
columns={columns}
data={items}
rowKey={(e) => e.id}
loading={loading}
emptyMessage="No environments yet."
/>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useState } from 'react';
import { keys } from '../api';
import { Table, type TableColumn } from '../ui';
import styles from './Page.module.css';
interface KeyRow { name: string }
const columns: TableColumn<KeyRow>[] = [
{ key: 'name', header: 'Key name', render: (k) => k.name },
];
export function KeysPage() {
const [items, setItems] = useState<KeyRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
keys.list()
.then((res) => setItems(res.keys.map((name) => ({ name }))))
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, []);
return (
<div>
<h1 className={styles.heading}>Keys</h1>
{error && <p className={styles.error}>{error}</p>}
<Table
columns={columns}
data={items}
rowKey={(k) => k.name}
loading={loading}
emptyMessage="No key files found."
/>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
.heading {
margin: 0 0 var(--space-4);
font-size: var(--font-size-lg);
font-weight: 700;
color: var(--color-text);
}
.error {
margin-bottom: var(--space-4);
padding: var(--space-3) var(--space-4);
background: var(--color-error-bg);
color: var(--color-error-fg);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
}
+67
View File
@@ -0,0 +1,67 @@
import { useCallback, useEffect, useState } from 'react';
import { scenarios } from '../api';
import type { Scenario } from '../api';
import { Button, Table, type TableColumn } from '../ui';
import styles from './Page.module.css';
export function ScenariosPage() {
const [items, setItems] = useState<Scenario[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
scenarios.list()
.then((res) => setItems(res.data))
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, []);
useEffect(load, [load]);
const handleRun = async (id: number) => {
await scenarios.run(id);
};
const handleDelete = async (id: number) => {
await scenarios.remove(id);
load();
};
const columns: TableColumn<Scenario>[] = [
{ key: 'id', header: 'ID', render: (s) => s.id, width: 60 },
{ key: 'name', header: 'Name', render: (s) => s.name },
{
key: 'updated',
header: 'Updated',
width: 120,
render: (s) => new Date(s.updatedAt).toLocaleDateString(),
},
{
key: 'actions',
header: '',
width: 140,
align: 'right',
render: (s) => (
<span style={{ display: 'inline-flex', gap: 6 }}>
<Button size="sm" onClick={() => handleRun(s.id)}>Run</Button>
<Button variant="secondary" size="sm" onClick={() => handleDelete(s.id)}>Delete</Button>
</span>
),
},
];
return (
<div>
<h1 className={styles.heading}>Scenarios</h1>
{error && <p className={styles.error}>{error}</p>}
<Table
columns={columns}
data={items}
rowKey={(s) => s.id}
loading={loading}
emptyMessage="No scenarios yet."
/>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useCallback, useEffect, useState } from 'react';
import { sessions } from '../api';
import type { Session } from '../api';
import { Badge, Button, Table, type TableColumn } from '../ui';
import styles from './Page.module.css';
export function SessionsPage() {
const [items, setItems] = useState<Session[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
sessions.list()
.then((res) => setItems(res.data))
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, []);
useEffect(load, [load]);
const handleDelete = async (id: number) => {
await sessions.remove(id);
load();
};
const columns: TableColumn<Session>[] = [
{ key: 'id', header: 'ID', render: (s) => s.id, width: 60 },
{ key: 'name', header: 'Name', render: (s) => s.sessionName },
{
key: 'status',
header: 'Status',
width: 100,
render: (s) => (
<Badge variant={s.status === 'open' ? 'success' : 'neutral'}>
{s.status}
</Badge>
),
},
{
key: 'lastUsed',
header: 'Last Used',
width: 160,
render: (s) => s.lastUsedAt ? new Date(s.lastUsedAt).toLocaleString() : '—',
},
{
key: 'actions',
header: '',
width: 80,
align: 'right',
render: (s) => (
<Button variant="secondary" size="sm" onClick={() => handleDelete(s.id)}>
Delete
</Button>
),
},
];
return (
<div>
<h1 className={styles.heading}>Sessions</h1>
{error && <p className={styles.error}>{error}</p>}
<Table
columns={columns}
data={items}
rowKey={(s) => s.id}
loading={loading}
emptyMessage="No sessions yet."
/>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--font-family);
font-size: var(--font-size-xs);
font-weight: 500;
padding: 2px var(--space-2);
border-radius: var(--radius-full);
white-space: nowrap;
line-height: 1.6;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.success { background: var(--color-success-bg); color: var(--color-success-fg); }
.error { background: var(--color-error-bg); color: var(--color-error-fg); }
.warning { background: var(--color-warning-bg); color: var(--color-warning-fg); }
.info { background: var(--color-info-bg); color: var(--color-info-fg); }
.neutral { background: var(--color-neutral-bg); color: var(--color-neutral-fg); }
.running { background: var(--color-running-bg); color: var(--color-running-fg); }
+25
View File
@@ -0,0 +1,25 @@
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 } };
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import styles from './Badge.module.css';
export type BadgeVariant = 'success' | 'error' | 'warning' | 'info' | 'neutral' | 'running';
export interface BadgeProps {
variant?: BadgeVariant;
dot?: boolean;
children: React.ReactNode;
}
export function Badge({ variant = 'neutral', dot = false, children }: BadgeProps) {
return (
<span className={[styles.badge, styles[variant]].join(' ')}>
{dot && <span className={styles.dot} aria-hidden="true" />}
{children}
</span>
);
}
@@ -0,0 +1,54 @@
.nav {
font-family: var(--font-family);
}
.list {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-1);
list-style: none;
margin: 0;
padding: 0;
}
.item {
display: flex;
align-items: center;
gap: var(--space-1);
}
.link {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-decoration: none;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-family: inherit;
border-radius: var(--radius-sm);
transition: color var(--transition);
}
.link:hover {
color: var(--color-text);
text-decoration: underline;
}
.link:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.current {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text);
}
.separator {
display: flex;
align-items: center;
color: var(--color-border);
}
@@ -0,0 +1,48 @@
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' },
],
},
};
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
import styles from './Breadcrumbs.module.css';
export interface BreadcrumbItem {
label: string;
href?: string;
onClick?: (e: React.MouseEvent) => void;
}
export interface BreadcrumbsProps {
items: BreadcrumbItem[];
separator?: React.ReactNode;
className?: string;
}
export function Breadcrumbs({ items, separator, className }: BreadcrumbsProps) {
const sep = separator ?? (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M4 2L8 6L4 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
return (
<nav aria-label="Breadcrumb" className={[styles.nav, className].filter(Boolean).join(' ')}>
<ol className={styles.list}>
{items.map((item, i) => {
const isLast = i === items.length - 1;
return (
<li key={i} className={styles.item}>
{isLast ? (
<span className={styles.current} aria-current="page">{item.label}</span>
) : item.href ? (
<a href={item.href} className={styles.link} onClick={item.onClick}>{item.label}</a>
) : (
<button type="button" className={styles.link} onClick={item.onClick}>{item.label}</button>
)}
{!isLast && <span className={styles.separator}>{sep}</span>}
</li>
);
})}
</ol>
</nav>
);
}
+89
View File
@@ -0,0 +1,89 @@
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
font-family: var(--font-family);
font-weight: 700;
border: var(--border-width) solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
white-space: nowrap;
outline: none;
text-decoration: none;
transition:
background var(--transition),
border-color var(--transition),
color var(--transition),
opacity var(--transition);
}
.button:focus-visible {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.35);
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Sizes */
.sm {
font-size: var(--font-size-sm);
padding: var(--space-1) var(--space-3);
height: 28px;
}
.md {
font-size: var(--font-size-md);
padding: var(--space-2) var(--space-4);
height: 36px;
}
.lg {
font-size: var(--font-size-lg);
padding: var(--space-3) var(--space-5);
height: 44px;
}
/* Variants */
.primary {
background: var(--color-primary);
color: var(--color-primary-fg);
}
.primary:hover:not(:disabled) { background: var(--color-primary-hover); }
.primary:active:not(:disabled) { background: var(--color-primary-active); }
.secondary {
background: var(--color-secondary);
color: var(--color-secondary-fg);
border-color: var(--color-secondary-border);
}
.secondary:hover:not(:disabled) { background: var(--color-secondary-hover); }
.secondary:active:not(:disabled) { background: var(--color-secondary-active); }
.ghost {
background: transparent;
color: var(--color-text);
}
.ghost:hover:not(:disabled) { background: var(--color-secondary); }
.ghost:active:not(:disabled) { background: var(--color-secondary-hover); }
.danger {
background: var(--color-danger);
color: var(--color-danger-fg);
}
.danger:hover:not(:disabled) { background: var(--color-danger-hover); }
/* Loading spinner */
.spinner {
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
+26
View File
@@ -0,0 +1,26 @@
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 } };
+29
View File
@@ -0,0 +1,29 @@
import React from 'react';
import styles from './Button.module.css';
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
}
export function Button({
variant = 'primary',
size = 'md',
loading = false,
disabled,
children,
className,
...props
}: ButtonProps) {
return (
<button
className={[styles.button, styles[variant], styles[size], className].filter(Boolean).join(' ')}
disabled={disabled || loading}
{...props}
>
{loading && <span className={styles.spinner} aria-hidden="true" />}
{children}
</button>
);
}
+11
View File
@@ -0,0 +1,11 @@
.card {
background: var(--color-bg-subtle);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
font-family: var(--font-family);
}
.sm { padding: var(--space-3) var(--space-4); }
.md { padding: var(--space-4) var(--space-6); }
.lg { padding: var(--space-6) var(--space-8); }
+46
View File
@@ -0,0 +1,46 @@
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>
),
};
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import styles from './Card.module.css';
export interface CardProps {
children: React.ReactNode;
className?: string;
padding?: 'sm' | 'md' | 'lg';
}
export function Card({ children, className, padding = 'md' }: CardProps) {
return (
<div className={[styles.card, styles[padding], className].filter(Boolean).join(' ')}>
{children}
</div>
);
}
+101
View File
@@ -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 /> };
+60
View File
@@ -0,0 +1,60 @@
.wrapper {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-family: var(--font-family);
width: 100%;
}
.label {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text);
}
.input {
font-family: var(--font-family);
font-size: var(--font-size-md);
color: var(--color-text);
background: var(--color-bg);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
height: 36px;
width: 100%;
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
}
.input::placeholder {
color: var(--color-text-muted);
}
.input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.input:disabled {
opacity: 0.5;
cursor: not-allowed;
background: var(--color-bg-subtle);
}
.hasError {
border-color: var(--color-error-fg);
}
.hasError:focus {
border-color: var(--color-error-fg);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
}
.hint {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.error {
font-size: var(--font-size-xs);
color: var(--color-error-fg);
}
+40
View File
@@ -0,0 +1,40 @@
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 },
};
+37
View File
@@ -0,0 +1,37 @@
import React, { useId } from 'react';
import styles from './Input.module.css';
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'id'> {
label?: string;
hint?: string;
error?: string;
}
export function Input({ label, hint, error, className, ...props }: InputProps) {
const id = useId();
return (
<div className={styles.wrapper}>
{label && (
<label htmlFor={id} className={styles.label}>
{label}
</label>
)}
<input
id={id}
className={[styles.input, error ? styles.hasError : '', className].filter(Boolean).join(' ')}
aria-describedby={error ? `${id}-error` : hint ? `${id}-hint` : undefined}
aria-invalid={error ? true : undefined}
{...props}
/>
{error ? (
<span id={`${id}-error`} className={styles.error} role="alert">
{error}
</span>
) : hint ? (
<span id={`${id}-hint`} className={styles.hint}>
{hint}
</span>
) : null}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
.wrapper {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-family: var(--font-family);
width: 100%;
}
.label {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text);
}
.control {
position: relative;
display: flex;
align-items: center;
}
.select {
font-family: var(--font-family);
font-size: var(--font-size-md);
color: var(--color-text);
background: var(--color-bg);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
padding-right: calc(var(--space-3) + 20px);
line-height: 1.5;
width: 100%;
outline: none;
appearance: none;
cursor: pointer;
transition: border-color var(--transition), box-shadow var(--transition);
}
.select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.select:disabled {
opacity: 0.5;
cursor: not-allowed;
background: var(--color-bg-subtle);
}
.hasError {
border-color: var(--color-error-fg);
}
.hasError:focus {
border-color: var(--color-error-fg);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-error-fg) 20%, transparent);
}
.chevron {
position: absolute;
right: var(--space-3);
pointer-events: none;
color: var(--color-text-muted);
display: flex;
align-items: center;
}
.select:disabled ~ .chevron {
opacity: 0.5;
}
.hint {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.error {
font-size: var(--font-size-xs);
color: var(--color-error-fg);
}
+75
View File
@@ -0,0 +1,75 @@
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,
},
};
+63
View File
@@ -0,0 +1,63 @@
import React, { useId } from 'react';
import styles from './Select.module.css';
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'id'> {
label?: string;
hint?: string;
error?: string;
options: SelectOption[];
placeholder?: string;
}
export function Select({ label, hint, error, options, placeholder, className, ...props }: SelectProps) {
const id = useId();
return (
<div className={styles.wrapper}>
{label && (
<label htmlFor={id} className={styles.label}>
{label}
</label>
)}
<div className={styles.control}>
<select
id={id}
className={[styles.select, error ? styles.hasError : '', className].filter(Boolean).join(' ')}
aria-describedby={error ? `${id}-error` : hint ? `${id}-hint` : undefined}
aria-invalid={error ? true : undefined}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((opt) => (
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
{opt.label}
</option>
))}
</select>
<span className={styles.chevron} aria-hidden="true">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2 4L6 8L10 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</div>
{error ? (
<span id={`${id}-error`} className={styles.error} role="alert">
{error}
</span>
) : hint ? (
<span id={`${id}-hint`} className={styles.hint}>
{hint}
</span>
) : null}
</div>
);
}
@@ -0,0 +1,88 @@
.panel {
position: relative;
display: flex;
flex-shrink: 0;
width: var(--side-panel-width, 240px);
background: var(--color-bg-subtle);
border-right: var(--border-width) solid var(--color-border);
font-family: var(--font-family);
transition: width 220ms ease;
overflow: visible;
}
.panel.collapsed {
width: 0;
}
.inner {
width: var(--side-panel-width, 240px);
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
flex: 1;
transition: opacity 180ms ease;
}
.collapsed .inner {
opacity: 0;
pointer-events: none;
}
.header {
padding: var(--space-4) var(--space-4) var(--space-3);
border-bottom: var(--border-width) solid var(--color-border);
font-weight: 600;
font-size: var(--font-size-sm);
color: var(--color-text);
flex-shrink: 0;
}
.content {
flex: 1;
overflow-y: auto;
padding: var(--space-3) var(--space-2);
}
/* ── Toggle button ───────────────────────────────────────── */
.toggle {
position: absolute;
top: var(--space-5);
right: -14px;
z-index: 10;
width: 28px;
height: 28px;
border-radius: var(--radius-full);
background: var(--color-bg);
border: var(--border-width) solid var(--color-border);
color: var(--color-text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-sm);
transition: background var(--transition), color var(--transition);
flex-shrink: 0;
}
.toggle:hover {
background: var(--color-secondary);
color: var(--color-secondary-fg);
border-color: var(--color-secondary-border);
}
.toggle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* ── Chevron rotation ────────────────────────────────────── */
.chevron {
transition: transform 220ms ease;
/* Points right (→) by default = collapse direction */
}
.collapsed .chevron {
transform: rotate(180deg);
/* Points left (←) = expand direction */
}
@@ -0,0 +1,83 @@
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 />,
},
};
+68
View File
@@ -0,0 +1,68 @@
import React, { useState } from 'react';
import styles from './SidePanel.module.css';
export interface SidePanelProps {
children: React.ReactNode;
header?: React.ReactNode;
width?: number;
defaultCollapsed?: boolean;
collapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
className?: string;
}
export function SidePanel({
children,
header,
width = 240,
defaultCollapsed = false,
collapsed: controlledCollapsed,
onCollapsedChange,
className,
}: SidePanelProps) {
const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed);
const isControlled = controlledCollapsed !== undefined;
const collapsed = isControlled ? controlledCollapsed : internalCollapsed;
function toggle() {
const next = !collapsed;
if (!isControlled) setInternalCollapsed(next);
onCollapsedChange?.(next);
}
return (
<aside
className={[styles.panel, collapsed ? styles.collapsed : '', className].filter(Boolean).join(' ')}
style={{ '--side-panel-width': `${width}px` } as React.CSSProperties}
aria-expanded={!collapsed}
>
<div className={styles.inner}>
{header && <div className={styles.header}>{header}</div>}
<div className={styles.content}>{children}</div>
</div>
<button
className={styles.toggle}
onClick={toggle}
aria-label={collapsed ? 'Expand panel' : 'Collapse panel'}
title={collapsed ? 'Expand' : 'Collapse'}
>
<svg
className={styles.chevron}
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="M6 3L11 8L6 13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</aside>
);
}
+56
View File
@@ -0,0 +1,56 @@
.wrapper {
width: 100%;
overflow-x: auto;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-subtle);
box-shadow: var(--shadow-sm);
}
.table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-family);
font-size: var(--font-size-sm);
color: var(--color-text);
}
.th {
padding: var(--space-3) var(--space-4);
font-weight: 700;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: var(--border-width) solid var(--color-border);
white-space: nowrap;
background: var(--color-bg-subtle);
}
/* Rounded top corners on first/last header cells */
.th:first-child { border-radius: var(--radius-lg) 0 0 0; }
.th:last-child { border-radius: 0 var(--radius-lg) 0 0; }
.tr {
transition: background var(--transition);
}
.tr:hover {
background: var(--color-bg);
}
.tr:not(:last-child) .td {
border-bottom: var(--border-width) solid var(--color-border);
}
.td {
padding: var(--space-3) var(--space-4);
vertical-align: middle;
}
.empty {
padding: var(--space-6) var(--space-4);
text-align: center;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
+71
View File
@@ -0,0 +1,71 @@
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]],
},
};
+80
View File
@@ -0,0 +1,80 @@
import type { ReactNode } from 'react';
import styles from './Table.module.css';
export interface TableColumn<T> {
key: string;
header: ReactNode;
render: (row: T) => ReactNode;
width?: string | number;
align?: 'left' | 'center' | 'right';
}
export interface TableProps<T> {
columns: TableColumn<T>[];
data: T[];
rowKey: (row: T) => string | number;
loading?: boolean;
emptyMessage?: string;
className?: string;
}
export function Table<T>({
columns,
data,
rowKey,
loading = false,
emptyMessage = 'No data.',
className,
}: TableProps<T>) {
return (
<div className={`${styles.wrapper} ${className ?? ''}`}>
<table className={styles.table}>
<thead>
<tr>
{columns.map((col) => (
<th
key={col.key}
className={styles.th}
style={{
width: col.width,
textAlign: col.align ?? 'left',
}}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={columns.length} className={styles.empty}>
Loading
</td>
</tr>
) : data.length === 0 ? (
<tr>
<td colSpan={columns.length} className={styles.empty}>
{emptyMessage}
</td>
</tr>
) : (
data.map((row) => (
<tr key={rowKey(row)} className={styles.tr}>
{columns.map((col) => (
<td
key={col.key}
className={styles.td}
style={{ textAlign: col.align ?? 'left' }}
>
{col.render(row)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
export { Button } from './Button/Button';
export type { ButtonProps } from './Button/Button';
export { Badge } from './Badge/Badge';
export type { BadgeProps, BadgeVariant } from './Badge/Badge';
export { Input } from './Input/Input';
export type { InputProps } from './Input/Input';
export { Select } from './Select/Select';
export type { SelectProps, SelectOption } from './Select/Select';
export { Card } from './Card/Card';
export type { CardProps } from './Card/Card';
export { SidePanel } from './SidePanel/SidePanel';
export type { SidePanelProps } from './SidePanel/SidePanel';
export { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';
export type { BreadcrumbsProps, BreadcrumbItem } from './Breadcrumbs/Breadcrumbs';
export { Table } from './Table/Table';
export type { TableProps, TableColumn } from './Table/Table';
+134
View File
@@ -0,0 +1,134 @@
:root {
/* ── Palette ──────────────────────────────────────────────────────────────
* Atlantis #9DD341 — primary / action
* Outer Space #2D3339 — text / surfaces
* Kimberly #6650C0 — accent / info / muted text
* Powder Ash #AFBFB9 — borders / neutral
* ──────────────────────────────────────────────────────────────────────── */
/* Primary — Atlantis */
--color-primary: #9DD341;
--color-primary-hover: #8ABD2E;
--color-primary-active: #77A320;
--color-primary-fg: #2D3339; /* Outer Space on bright green */
/* Secondary — Kimberly tint */
--color-secondary: #EDEAF8;
--color-secondary-hover: #DDD8F2;
--color-secondary-active: #CCC5EB;
--color-secondary-fg: #6650C0; /* Kimberly */
--color-secondary-border: #B8B0E0;
/* Danger */
--color-danger: #D94F4F;
--color-danger-hover: #C03A3A;
--color-danger-fg: #ffffff;
/* Semantic */
--color-success-bg: #EEF8D6; /* Atlantis tint */
--color-success-fg: #3E6010;
--color-error-bg: #FAEAEA;
--color-error-fg: #8B2020;
--color-warning-bg: #FEF5E0;
--color-warning-fg: #7A4E00;
--color-info-bg: #EEEDF6; /* Kimberly tint */
--color-info-fg: #4A4270;
--color-neutral-bg: #EDF1EF; /* Powder Ash tint */
--color-neutral-fg: #4D6059;
--color-running-bg: #FEF9C3;
--color-running-fg: #6B4A00;
/* Text & Surface — Outer Space */
--color-text: #2D3339;
--color-text-muted: #6650C0; /* Kimberly */
--color-border: #AFBFB9; /* Powder Ash */
--color-bg: #E6EDEA; /* Powder Ash tint */
--color-bg-subtle: #D8E2DE; /* Powder Ash tint, 1 step darker */
/* Spacing */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
/* Border radius */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-full: 9999px;
/* Typography */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-size-xs: 13px;
--font-size-sm: 15px;
--font-size-md: 17px;
--font-size-lg: 20px;
/* Borders */
--border-width: 1.5px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -1px rgba(0, 0, 0, 0.04);
/* Transitions */
--transition: 150ms ease;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
font-family: var(--font-family);
color: var(--color-text);
background: var(--color-bg);
margin: 0;
}
/* ── Dark theme ─────────────────────────────────────────────────────────── */
[data-theme="dark"] {
/* Primary — Atlantis unchanged, pops well on dark */
--color-primary: #9DD341;
--color-primary-hover: #AEDE55;
--color-primary-active: #8ABD2E;
--color-primary-fg: #1A1F24;
/* Secondary — dark Kimberly tint */
--color-secondary: #2A2548;
--color-secondary-hover: #352F58;
--color-secondary-active: #403868;
--color-secondary-fg: #A9A3C9; /* lightened Kimberly */
--color-secondary-border: #5A538A;
/* Danger */
--color-danger: #E06060;
--color-danger-hover: #CC4A4A;
--color-danger-fg: #1A1F24;
/* Semantic */
--color-success-bg: #1A3410;
--color-success-fg: #9DD341;
--color-error-bg: #3A1212;
--color-error-fg: #F08888;
--color-warning-bg: #3A2800;
--color-warning-fg: #F0C860;
--color-info-bg: #1E1A38;
--color-info-fg: #B0AAD6; /* lightened Kimberly */
--color-neutral-bg: #2D3339;
--color-neutral-fg: #AFBFB9;
--color-running-bg: #382E00;
--color-running-fg: #F0D060;
/* Text & Surface */
--color-text: #E2EAE6;
--color-text-muted: #A9A3C9; /* lightened Kimberly */
--color-border: #3D4850;
--color-bg: #1A1F24; /* deeper than Outer Space */
--color-bg-subtle: #2D3339; /* Outer Space */
}
+3 -2
View File
@@ -11,7 +11,8 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true
"strict": true,
"types": ["vite/client"]
},
"include": ["src"]
"include": ["src", ".storybook"]
}
+41 -3
View File
@@ -1,6 +1,44 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from '@vitest/browser-playwright';
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
export default defineConfig({
plugins: [react()],
})
server: {
proxy: {
'/environments': 'http://localhost:13000',
'/sessions': 'http://localhost:13000',
'/scenarios': 'http://localhost:13000',
'/keys': 'http://localhost:13000',
'/login': 'http://localhost:13000',
},
},
test: {
projects: [{
extends: true,
plugins: [
// The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest({
configDir: path.join(dirname, '.storybook')
})],
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: playwright({}),
instances: [{
browser: 'chromium'
}]
}
}
}]
}
});
+1
View File
@@ -0,0 +1 @@
/// <reference types="@vitest/browser-playwright" />
+1738 -1
View File
File diff suppressed because it is too large Load Diff