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
+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 */
}