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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user