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:
+3
-1
@@ -10,4 +10,6 @@ node_modules/
|
|||||||
# build outputs
|
# build outputs
|
||||||
/dist
|
/dist
|
||||||
server/dist
|
server/dist
|
||||||
client/dist
|
client/dist
|
||||||
|
*storybook.log
|
||||||
|
storybook-static
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
@@ -6,17 +6,28 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"storybook": "storybook dev -p 6006",
|
||||||
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
"@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",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^6.3.1"
|
"vite": "^6.3.1",
|
||||||
|
"vitest": "^4.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './client';
|
||||||
|
export * from './types';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
declare module '*.css' {
|
||||||
|
const styles: Record<string, string>;
|
||||||
|
export default styles;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL?: string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './ui/tokens.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
@@ -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 } };
|
||||||
@@ -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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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 } };
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
@@ -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>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
const palette = [
|
||||||
|
{ name: 'Atlantis', token: '--color-primary', hex: '#9DD341', role: 'Primary / Action' },
|
||||||
|
{ name: 'Outer Space', token: '--color-text', hex: '#2D3339', role: 'Text / Surface' },
|
||||||
|
{ name: 'Kimberly', token: '--color-text-muted', hex: '#6650C0', role: 'Muted text / Info' },
|
||||||
|
{ name: 'Powder Ash', token: '--color-border', hex: '#AFBFB9', role: 'Border / Neutral' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const semanticColors = [
|
||||||
|
{ name: 'Success bg', token: '--color-success-bg', hex: '#EEF8D6' },
|
||||||
|
{ name: 'Success fg', token: '--color-success-fg', hex: '#3E6010' },
|
||||||
|
{ name: 'Error bg', token: '--color-error-bg', hex: '#FAEAEA' },
|
||||||
|
{ name: 'Error fg', token: '--color-error-fg', hex: '#8B2020' },
|
||||||
|
{ name: 'Warning bg', token: '--color-warning-bg', hex: '#FEF5E0' },
|
||||||
|
{ name: 'Warning fg', token: '--color-warning-fg', hex: '#7A4E00' },
|
||||||
|
{ name: 'Info bg', token: '--color-info-bg', hex: '#EEEDF6' },
|
||||||
|
{ name: 'Info fg', token: '--color-info-fg', hex: '#4A4270' },
|
||||||
|
{ name: 'Neutral bg', token: '--color-neutral-bg', hex: '#EDF1EF' },
|
||||||
|
{ name: 'Neutral fg', token: '--color-neutral-fg', hex: '#4D6059' },
|
||||||
|
{ name: 'Running bg', token: '--color-running-bg', hex: '#FEF9C3' },
|
||||||
|
{ name: 'Running fg', token: '--color-running-fg', hex: '#6B4A00' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const surfaceColors = [
|
||||||
|
{ name: 'Background', token: '--color-bg', hex: '#E6EDEA' },
|
||||||
|
{ name: 'Background subtle', token: '--color-bg-subtle', hex: '#D8E2DE' },
|
||||||
|
{ name: 'Secondary', token: '--color-secondary', hex: '#EDF1EF' },
|
||||||
|
{ name: 'Secondary hover', token: '--color-secondary-hover', hex: '#DFE6E3' },
|
||||||
|
{ name: 'Danger', token: '--color-danger', hex: '#D94F4F' },
|
||||||
|
{ name: 'Primary fg', token: '--color-primary-fg', hex: '#2D3339' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Swatch({ name, token, hex, role }: { name: string; token: string; hex: string; role?: string }) {;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: 'var(--border-width) solid var(--color-border)',
|
||||||
|
minWidth: 160,
|
||||||
|
flex: '1 1 160px',
|
||||||
|
maxWidth: 220,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: `var(${token}, ${hex})`,
|
||||||
|
height: 80,
|
||||||
|
}} />
|
||||||
|
<div style={{ padding: '10px 12px', background: 'var(--color-bg)' }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13, color: 'var(--color-text)', marginBottom: 2 }}>{name}</div>
|
||||||
|
{role && <div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>{role}</div>}
|
||||||
|
<div style={{ fontSize: 11, fontFamily: 'monospace', color: 'var(--color-text-muted)' }}>{hex}</div>
|
||||||
|
<div style={{ fontSize: 11, fontFamily: 'monospace', color: 'var(--color-text-muted)' }}>{token}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 40 }}>
|
||||||
|
<h2 style={{ fontFamily: 'var(--font-family)', color: 'var(--color-text)', fontSize: 14, fontWeight: 600, marginBottom: 16, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorPalette() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 32, fontFamily: 'var(--font-family)' }}>
|
||||||
|
<h1 style={{ color: 'var(--color-text)', fontSize: 22, fontWeight: 700, marginBottom: 8 }}>Color Palette</h1>
|
||||||
|
<p style={{ color: 'var(--color-text-muted)', fontSize: 14, marginBottom: 40 }}>
|
||||||
|
All colors are exposed as CSS custom properties on <code>:root</code> via <code>tokens.css</code>.
|
||||||
|
</p>
|
||||||
|
<Section title="Brand">
|
||||||
|
{palette.map(c => <Swatch key={c.token} {...c} />)}
|
||||||
|
</Section>
|
||||||
|
<Section title="Semantic">
|
||||||
|
{semanticColors.map(c => <Swatch key={c.token} {...c} />)}
|
||||||
|
</Section>
|
||||||
|
<Section title="Surface & Action">
|
||||||
|
{surfaceColors.map(c => <Swatch key={c.token} {...c} />)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: Meta = {
|
||||||
|
title: 'Design Tokens/Colors',
|
||||||
|
parameters: { layout: 'fullscreen' },
|
||||||
|
};
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj;
|
||||||
|
export const All: Story = { render: () => <ColorPalette /> };
|
||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 />,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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]],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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 */
|
||||||
|
}
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true
|
"strict": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", ".storybook"]
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-3
@@ -1,6 +1,44 @@
|
|||||||
import { defineConfig } from 'vite'
|
/// <reference types="vitest/config" />
|
||||||
import react from '@vitejs/plugin-react'
|
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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
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'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="@vitest/browser-playwright" />
|
||||||
Generated
+1738
-1
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user