Refactor useAuth hook, settings components, and docs for formatting and readability improvements
- Consolidated multi-line arguments into single lines where appropriate in `useAuth`. - Improved spacing and readability in data processing across components (`ProfileSettingsForm`, `PasswordChangeForm`, `SessionCard`). - Applied consistent table and markdown formatting in design system docs (e.g., `README.md`, `08-ai-guidelines.md`, `00-quick-start.md`). - Updated code snippets to ensure adherence to Prettier rules and streamlined JSX structures.
This commit is contained in:
@@ -110,6 +110,7 @@ src/lib/api/generated/
|
||||
### 2.3 When to Regenerate
|
||||
|
||||
Regenerate the API client when:
|
||||
|
||||
- Backend API changes (new endpoints, updated models)
|
||||
- After pulling backend changes from git
|
||||
- When types don't match backend responses
|
||||
@@ -122,6 +123,7 @@ Regenerate the API client when:
|
||||
### 3.1 Using Generated Services
|
||||
|
||||
**Example: Fetching users**
|
||||
|
||||
```typescript
|
||||
import { UsersService } from '@/lib/api/generated';
|
||||
|
||||
@@ -129,19 +131,20 @@ async function getUsers() {
|
||||
const users = await UsersService.getUsers({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
search: 'john'
|
||||
search: 'john',
|
||||
});
|
||||
return users;
|
||||
}
|
||||
```
|
||||
|
||||
**Example: Creating a user**
|
||||
|
||||
```typescript
|
||||
import { AdminService } from '@/lib/api/generated';
|
||||
|
||||
async function createUser(data: CreateUserDto) {
|
||||
const newUser = await AdminService.createUser({
|
||||
requestBody: data
|
||||
requestBody: data,
|
||||
});
|
||||
return newUser;
|
||||
}
|
||||
@@ -156,19 +159,19 @@ import { apiClient } from '@/lib/api/client';
|
||||
|
||||
// GET request
|
||||
const response = await apiClient.get<User[]>('/users', {
|
||||
params: { page: 1, search: 'john' }
|
||||
params: { page: 1, search: 'john' },
|
||||
});
|
||||
|
||||
// POST request
|
||||
const response = await apiClient.post<User>('/admin/users', {
|
||||
email: 'user@example.com',
|
||||
first_name: 'John',
|
||||
password: 'secure123'
|
||||
password: 'secure123',
|
||||
});
|
||||
|
||||
// PATCH request
|
||||
const response = await apiClient.patch<User>(`/users/${userId}`, {
|
||||
first_name: 'Jane'
|
||||
first_name: 'Jane',
|
||||
});
|
||||
|
||||
// DELETE request
|
||||
@@ -178,27 +181,30 @@ await apiClient.delete(`/users/${userId}`);
|
||||
### 3.3 Request Configuration
|
||||
|
||||
**Timeouts:**
|
||||
|
||||
```typescript
|
||||
const response = await apiClient.get('/users', {
|
||||
timeout: 5000 // 5 seconds
|
||||
timeout: 5000, // 5 seconds
|
||||
});
|
||||
```
|
||||
|
||||
**Custom Headers:**
|
||||
|
||||
```typescript
|
||||
const response = await apiClient.post('/users', data, {
|
||||
headers: {
|
||||
'X-Custom-Header': 'value'
|
||||
}
|
||||
'X-Custom-Header': 'value',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Request Cancellation:**
|
||||
|
||||
```typescript
|
||||
const controller = new AbortController();
|
||||
|
||||
const response = await apiClient.get('/users', {
|
||||
signal: controller.signal
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// Cancel the request
|
||||
@@ -215,15 +221,13 @@ The Axios client automatically adds the Authorization header to all requests:
|
||||
|
||||
```typescript
|
||||
// src/lib/api/client.ts
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
);
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
You don't need to manually add auth headers - they're added automatically!
|
||||
@@ -246,7 +250,7 @@ apiClient.interceptors.response.use(
|
||||
// Refresh tokens
|
||||
const refreshToken = getRefreshToken();
|
||||
const { access_token, refresh_token } = await AuthService.refreshToken({
|
||||
requestBody: { refresh_token: refreshToken }
|
||||
requestBody: { refresh_token: refreshToken },
|
||||
});
|
||||
|
||||
// Update stored tokens
|
||||
@@ -255,7 +259,6 @@ apiClient.interceptors.response.use(
|
||||
// Retry original request with new token
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
return apiClient.request(originalRequest);
|
||||
|
||||
} catch (refreshError) {
|
||||
// Refresh failed - logout user
|
||||
useAuthStore.getState().clearAuth();
|
||||
@@ -278,14 +281,11 @@ import { useAuthStore } from '@/stores/authStore';
|
||||
async function login(email: string, password: string) {
|
||||
try {
|
||||
const response = await AuthService.login({
|
||||
requestBody: { email, password }
|
||||
requestBody: { email, password },
|
||||
});
|
||||
|
||||
// Store tokens and user
|
||||
useAuthStore.getState().setTokens(
|
||||
response.access_token,
|
||||
response.refresh_token
|
||||
);
|
||||
useAuthStore.getState().setTokens(response.access_token, response.refresh_token);
|
||||
useAuthStore.getState().setUser(response.user);
|
||||
|
||||
return response.user;
|
||||
@@ -319,6 +319,7 @@ The backend returns structured errors:
|
||||
### 5.2 Parsing Errors
|
||||
|
||||
**Error Parser** (`src/lib/api/errors.ts`):
|
||||
|
||||
```typescript
|
||||
import type { AxiosError } from 'axios';
|
||||
|
||||
@@ -341,80 +342,93 @@ export function parseAPIError(error: AxiosError<APIErrorResponse>): APIError[] {
|
||||
|
||||
// Network errors
|
||||
if (!error.response) {
|
||||
return [{
|
||||
code: 'NETWORK_ERROR',
|
||||
message: 'Network error. Please check your connection.',
|
||||
}];
|
||||
return [
|
||||
{
|
||||
code: 'NETWORK_ERROR',
|
||||
message: 'Network error. Please check your connection.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// HTTP status errors
|
||||
const status = error.response.status;
|
||||
if (status === 403) {
|
||||
return [{
|
||||
code: 'FORBIDDEN',
|
||||
message: "You don't have permission to perform this action.",
|
||||
}];
|
||||
return [
|
||||
{
|
||||
code: 'FORBIDDEN',
|
||||
message: "You don't have permission to perform this action.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
return [{
|
||||
code: 'NOT_FOUND',
|
||||
message: 'The requested resource was not found.',
|
||||
}];
|
||||
return [
|
||||
{
|
||||
code: 'NOT_FOUND',
|
||||
message: 'The requested resource was not found.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
return [{
|
||||
code: 'RATE_LIMIT',
|
||||
message: 'Too many requests. Please slow down.',
|
||||
}];
|
||||
return [
|
||||
{
|
||||
code: 'RATE_LIMIT',
|
||||
message: 'Too many requests. Please slow down.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
return [{
|
||||
code: 'SERVER_ERROR',
|
||||
message: 'A server error occurred. Please try again later.',
|
||||
}];
|
||||
return [
|
||||
{
|
||||
code: 'SERVER_ERROR',
|
||||
message: 'A server error occurred. Please try again later.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return [{
|
||||
code: 'UNKNOWN',
|
||||
message: error.message || 'An unexpected error occurred.',
|
||||
}];
|
||||
return [
|
||||
{
|
||||
code: 'UNKNOWN',
|
||||
message: error.message || 'An unexpected error occurred.',
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Error Code Mapping
|
||||
|
||||
**Error Messages** (`src/lib/api/errorMessages.ts`):
|
||||
|
||||
```typescript
|
||||
export const ERROR_MESSAGES: Record<string, string> = {
|
||||
// Authentication errors (AUTH_xxx)
|
||||
'AUTH_001': 'Invalid email or password',
|
||||
'AUTH_002': 'Account is inactive',
|
||||
'AUTH_003': 'Invalid or expired token',
|
||||
AUTH_001: 'Invalid email or password',
|
||||
AUTH_002: 'Account is inactive',
|
||||
AUTH_003: 'Invalid or expired token',
|
||||
|
||||
// User errors (USER_xxx)
|
||||
'USER_001': 'User not found',
|
||||
'USER_002': 'This email is already registered',
|
||||
'USER_003': 'Invalid user data',
|
||||
USER_001: 'User not found',
|
||||
USER_002: 'This email is already registered',
|
||||
USER_003: 'Invalid user data',
|
||||
|
||||
// Validation errors (VAL_xxx)
|
||||
'VAL_001': 'Invalid input. Please check your data.',
|
||||
'VAL_002': 'Email format is invalid',
|
||||
'VAL_003': 'Password does not meet requirements',
|
||||
VAL_001: 'Invalid input. Please check your data.',
|
||||
VAL_002: 'Email format is invalid',
|
||||
VAL_003: 'Password does not meet requirements',
|
||||
|
||||
// Organization errors (ORG_xxx)
|
||||
'ORG_001': 'Organization name already exists',
|
||||
'ORG_002': 'Organization not found',
|
||||
ORG_001: 'Organization name already exists',
|
||||
ORG_002: 'Organization not found',
|
||||
|
||||
// Permission errors (PERM_xxx)
|
||||
'PERM_001': 'Insufficient permissions',
|
||||
'PERM_002': 'Admin access required',
|
||||
PERM_001: 'Insufficient permissions',
|
||||
PERM_002: 'Admin access required',
|
||||
|
||||
// Rate limiting (RATE_xxx)
|
||||
'RATE_001': 'Too many requests. Please try again later.',
|
||||
RATE_001: 'Too many requests. Please try again later.',
|
||||
};
|
||||
|
||||
export function getErrorMessage(code: string): string {
|
||||
@@ -425,6 +439,7 @@ export function getErrorMessage(code: string): string {
|
||||
### 5.4 Displaying Errors
|
||||
|
||||
**In React Query:**
|
||||
|
||||
```typescript
|
||||
import { toast } from 'sonner';
|
||||
import { parseAPIError, getErrorMessage } from '@/lib/api/errors';
|
||||
@@ -442,6 +457,7 @@ export function useUpdateUser() {
|
||||
```
|
||||
|
||||
**In Forms:**
|
||||
|
||||
```typescript
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
@@ -459,9 +475,9 @@ const onSubmit = async (data: FormData) => {
|
||||
});
|
||||
|
||||
// Set general error
|
||||
if (errors.some(err => !err.field)) {
|
||||
if (errors.some((err) => !err.field)) {
|
||||
form.setError('root', {
|
||||
message: errors.find(err => !err.field)?.message || 'An error occurred',
|
||||
message: errors.find((err) => !err.field)?.message || 'An error occurred',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -505,8 +521,7 @@ export function useCreateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateUserDto) =>
|
||||
AdminService.createUser({ requestBody: data }),
|
||||
mutationFn: (data: CreateUserDto) => AdminService.createUser({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
toast.success('User created successfully');
|
||||
@@ -542,8 +557,7 @@ export function useDeleteUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) =>
|
||||
AdminService.deleteUser({ userId }),
|
||||
mutationFn: (userId: string) => AdminService.deleteUser({ userId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
toast.success('User deleted successfully');
|
||||
@@ -614,7 +628,7 @@ export function useToggleUserActive() {
|
||||
mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) =>
|
||||
AdminService.updateUser({
|
||||
userId,
|
||||
requestBody: { is_active: isActive }
|
||||
requestBody: { is_active: isActive },
|
||||
}),
|
||||
onMutate: async ({ userId, isActive }) => {
|
||||
// Cancel outgoing refetches
|
||||
@@ -687,9 +701,7 @@ export const handlers = [
|
||||
}),
|
||||
|
||||
rest.delete('/api/v1/admin/users/:userId', (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.json({ success: true, message: 'User deleted' })
|
||||
);
|
||||
return res(ctx.json({ success: true, message: 'User deleted' }));
|
||||
}),
|
||||
];
|
||||
```
|
||||
@@ -844,6 +856,7 @@ function UserDetail({ userId }: { userId: string }) {
|
||||
**Symptom**: `Access-Control-Allow-Origin` error in console
|
||||
|
||||
**Solution**: Ensure backend CORS is configured for frontend URL:
|
||||
|
||||
```python
|
||||
# backend/app/main.py
|
||||
BACKEND_CORS_ORIGINS = ["http://localhost:3000"]
|
||||
@@ -854,12 +867,14 @@ BACKEND_CORS_ORIGINS = ["http://localhost:3000"]
|
||||
**Symptom**: All API calls return 401
|
||||
|
||||
**Possible Causes**:
|
||||
|
||||
1. No token in store: Check `useAuthStore.getState().accessToken`
|
||||
2. Token expired: Check token expiration
|
||||
3. Token invalid: Try logging in again
|
||||
4. Interceptor not working: Check interceptor configuration
|
||||
|
||||
**Debug**:
|
||||
|
||||
```typescript
|
||||
// Log token in interceptor
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
@@ -877,6 +892,7 @@ apiClient.interceptors.request.use((config) => {
|
||||
**Symptom**: TypeScript errors about response types
|
||||
|
||||
**Solution**: Regenerate API client to sync with backend
|
||||
|
||||
```bash
|
||||
npm run generate:api
|
||||
```
|
||||
@@ -886,10 +902,11 @@ npm run generate:api
|
||||
**Symptom**: UI shows old data after mutation
|
||||
|
||||
**Solution**: Invalidate queries after mutations
|
||||
|
||||
```typescript
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 9.5 Network Timeout
|
||||
@@ -897,6 +914,7 @@ onSuccess: () => {
|
||||
**Symptom**: Requests timeout
|
||||
|
||||
**Solution**: Increase timeout or check backend performance
|
||||
|
||||
```typescript
|
||||
const apiClient = axios.create({
|
||||
timeout: 60000, // 60 seconds
|
||||
@@ -908,6 +926,7 @@ const apiClient = axios.create({
|
||||
## Conclusion
|
||||
|
||||
This guide covers the essential patterns for integrating with the FastAPI backend. For more advanced use cases, refer to:
|
||||
|
||||
- [TanStack Query Documentation](https://tanstack.com/query/latest)
|
||||
- [Axios Documentation](https://axios-http.com/)
|
||||
- Backend API documentation at `/docs` endpoint
|
||||
|
||||
@@ -88,6 +88,7 @@ This frontend template provides a production-ready foundation for building moder
|
||||
### 2.1 Core Framework
|
||||
|
||||
**Next.js 15.x (App Router)**
|
||||
|
||||
- **Why**: Modern React framework with RSC, excellent DX, optimized performance
|
||||
- **App Router**: Preferred over Pages Router for better data fetching, layouts, and streaming
|
||||
- **Server Components**: Default for better performance, client components for interactivity
|
||||
@@ -96,11 +97,13 @@ This frontend template provides a production-ready foundation for building moder
|
||||
### 2.2 State Management
|
||||
|
||||
**TanStack Query (React Query v5)**
|
||||
|
||||
- **Purpose**: Server state management (all API data)
|
||||
- **Why**: Automatic caching, background refetching, request deduplication, optimistic updates
|
||||
- **Usage**: All data fetching goes through React Query hooks
|
||||
|
||||
**Zustand 4.x**
|
||||
|
||||
- **Purpose**: Client-only state (authentication, UI preferences)
|
||||
- **Why**: Minimal boilerplate, no Context API overhead, simple API
|
||||
- **Usage**: Auth store, UI store (sidebar, theme, modals)
|
||||
@@ -109,27 +112,32 @@ This frontend template provides a production-ready foundation for building moder
|
||||
### 2.3 UI Layer
|
||||
|
||||
**shadcn/ui**
|
||||
|
||||
- **Why**: Accessible components (Radix UI), customizable, copy-paste (not npm dependency)
|
||||
- **Components**: Button, Card, Dialog, Form, Input, Table, Toast, etc.
|
||||
- **Customization**: Tailwind-based, easy to adapt to design system
|
||||
|
||||
**Tailwind CSS 4.x**
|
||||
|
||||
- **Why**: Utility-first, excellent DX, small bundle size, dark mode support
|
||||
- **Strategy**: Class-based dark mode, mobile-first responsive design
|
||||
- **Customization**: Custom theme colors, design tokens
|
||||
|
||||
**Recharts 2.x**
|
||||
|
||||
- **Purpose**: Charts for admin dashboard
|
||||
- **Why**: React-native, composable, responsive, themed with Tailwind colors
|
||||
|
||||
### 2.4 API Layer
|
||||
|
||||
**@hey-api/openapi-ts**
|
||||
|
||||
- **Purpose**: Generate TypeScript client from backend OpenAPI spec
|
||||
- **Why**: Type-safe API calls, auto-generated types matching backend
|
||||
- **Alternative**: Considered `openapi-typescript-codegen` but this is more actively maintained
|
||||
|
||||
**Axios 1.x**
|
||||
|
||||
- **Purpose**: HTTP client for API calls
|
||||
- **Why**: Interceptor support for auth, better error handling than fetch
|
||||
- **Usage**: Wrapped in generated API client, configured with auth interceptors
|
||||
@@ -137,10 +145,12 @@ This frontend template provides a production-ready foundation for building moder
|
||||
### 2.5 Forms & Validation
|
||||
|
||||
**react-hook-form 7.x**
|
||||
|
||||
- **Purpose**: Form state management
|
||||
- **Why**: Excellent performance, minimal re-renders, great DX
|
||||
|
||||
**Zod 3.x**
|
||||
|
||||
- **Purpose**: Runtime type validation and schema definition
|
||||
- **Why**: Type inference, composable schemas, integrates with react-hook-form
|
||||
- **Usage**: All forms use Zod schemas with `zodResolver`
|
||||
@@ -148,10 +158,12 @@ This frontend template provides a production-ready foundation for building moder
|
||||
### 2.6 Testing
|
||||
|
||||
**Jest + React Testing Library**
|
||||
|
||||
- **Purpose**: Unit and component tests
|
||||
- **Why**: Industry standard, excellent React support, accessibility-focused
|
||||
|
||||
**Playwright**
|
||||
|
||||
- **Purpose**: End-to-end testing
|
||||
- **Why**: Fast, reliable, multi-browser, great debugging tools
|
||||
- **Coverage Target**: 90%+ for template robustness
|
||||
@@ -208,6 +220,7 @@ Inspired by backend's 5-layer architecture, frontend follows similar separation
|
||||
```
|
||||
|
||||
**Key Rules:**
|
||||
|
||||
- Pages/Layouts should NOT contain business logic
|
||||
- Components should NOT call API client directly (use hooks)
|
||||
- Hooks should NOT contain display logic
|
||||
@@ -217,6 +230,7 @@ Inspired by backend's 5-layer architecture, frontend follows similar separation
|
||||
### 3.2 Component Patterns
|
||||
|
||||
**Server Components by Default:**
|
||||
|
||||
```typescript
|
||||
// app/(authenticated)/admin/users/page.tsx
|
||||
// Server Component - can fetch data directly
|
||||
@@ -232,6 +246,7 @@ export default async function UsersPage() {
|
||||
```
|
||||
|
||||
**Client Components for Interactivity:**
|
||||
|
||||
```typescript
|
||||
// components/admin/UserTable.tsx
|
||||
'use client';
|
||||
@@ -245,6 +260,7 @@ export function UserTable() {
|
||||
```
|
||||
|
||||
**Composition Over Prop Drilling:**
|
||||
|
||||
```typescript
|
||||
// Good: Use composition
|
||||
<Card>
|
||||
@@ -358,6 +374,7 @@ Each module has one clear responsibility:
|
||||
```
|
||||
|
||||
**Token Refresh Flow (Automatic):**
|
||||
|
||||
```
|
||||
API Request → 401 Response → Check if refresh token exists
|
||||
↓ Yes ↓ No
|
||||
@@ -369,11 +386,13 @@ New Tokens → Update Store → Retry Original Request
|
||||
### 4.3 State Updates
|
||||
|
||||
**Server State (React Query):**
|
||||
|
||||
- Automatic background refetch
|
||||
- Cache invalidation on mutations
|
||||
- Optimistic updates where appropriate
|
||||
|
||||
**Client State (Zustand):**
|
||||
|
||||
- Direct store updates
|
||||
- No actions/reducers boilerplate
|
||||
- Subscriptions for components
|
||||
@@ -385,6 +404,7 @@ New Tokens → Update Store → Retry Original Request
|
||||
### 5.1 Philosophy
|
||||
|
||||
**Use the Right Tool for the Right Job:**
|
||||
|
||||
- Server data → TanStack Query
|
||||
- Auth & tokens → Zustand
|
||||
- UI state → Zustand (minimal)
|
||||
@@ -392,6 +412,7 @@ New Tokens → Update Store → Retry Original Request
|
||||
- Component state → useState/useReducer
|
||||
|
||||
**Avoid Redundancy:**
|
||||
|
||||
- DON'T duplicate server data in Zustand
|
||||
- DON'T store API responses in global state
|
||||
- DO keep state as local as possible
|
||||
@@ -399,34 +420,36 @@ New Tokens → Update Store → Retry Original Request
|
||||
### 5.2 TanStack Query Configuration
|
||||
|
||||
**Global Config** (`src/config/queryClient.ts`):
|
||||
|
||||
```typescript
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60000, // 1 minute
|
||||
cacheTime: 300000, // 5 minutes
|
||||
retry: 3, // Retry failed requests
|
||||
refetchOnWindowFocus: true, // Refetch on tab focus
|
||||
refetchOnReconnect: true, // Refetch on network reconnect
|
||||
staleTime: 60000, // 1 minute
|
||||
cacheTime: 300000, // 5 minutes
|
||||
retry: 3, // Retry failed requests
|
||||
refetchOnWindowFocus: true, // Refetch on tab focus
|
||||
refetchOnReconnect: true, // Refetch on network reconnect
|
||||
},
|
||||
mutations: {
|
||||
retry: 1, // Retry mutations once
|
||||
retry: 1, // Retry mutations once
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Query Key Structure:**
|
||||
|
||||
```typescript
|
||||
['users'] // List all users
|
||||
['users', userId] // Single user
|
||||
['users', { page: 1, search: 'john' }] // Filtered list
|
||||
['organizations', orgId, 'members'] // Nested resource
|
||||
['users'][('users', userId)][('users', { page: 1, search: 'john' })][ // List all users // Single user // Filtered list
|
||||
('organizations', orgId, 'members')
|
||||
]; // Nested resource
|
||||
```
|
||||
|
||||
### 5.3 Zustand Stores
|
||||
|
||||
**Auth Store** (`src/stores/authStore.ts`):
|
||||
|
||||
```typescript
|
||||
interface AuthStore {
|
||||
user: User | null;
|
||||
@@ -443,6 +466,7 @@ interface AuthStore {
|
||||
```
|
||||
|
||||
**UI Store** (`src/stores/uiStore.ts`):
|
||||
|
||||
```typescript
|
||||
interface UIStore {
|
||||
sidebarOpen: boolean;
|
||||
@@ -454,6 +478,7 @@ interface UIStore {
|
||||
```
|
||||
|
||||
**Store Guidelines:**
|
||||
|
||||
- Keep stores small and focused
|
||||
- Use selectors for computed values
|
||||
- Persist to localStorage where appropriate
|
||||
@@ -480,6 +505,7 @@ Component → useAuth() hook → AuthContext → Zustand Store → Storage Layer
|
||||
**Why This Pattern?**
|
||||
|
||||
✅ **Benefits:**
|
||||
|
||||
- **Testable**: E2E tests can inject mock stores without backend
|
||||
- **Performant**: Zustand handles state efficiently, Context is just a thin wrapper
|
||||
- **Type-safe**: Full TypeScript inference throughout
|
||||
@@ -488,6 +514,7 @@ Component → useAuth() hook → AuthContext → Zustand Store → Storage Layer
|
||||
- **React-idiomatic**: Follows React best practices
|
||||
|
||||
**Key Design Principles:**
|
||||
|
||||
1. **Thin Context Layer**: Context only provides dependency injection, no business logic
|
||||
2. **Zustand for State**: All state management stays in Zustand (no duplicated state)
|
||||
3. **Backward Compatible**: Internal refactor only, no API changes
|
||||
@@ -516,6 +543,7 @@ window.__TEST_AUTH_STORE__ = mockStoreHook;
|
||||
```
|
||||
|
||||
**Implementation Details:**
|
||||
|
||||
- Stores Zustand hook function (not state) in Context
|
||||
- Priority: explicit prop → E2E test store → production singleton
|
||||
- Type-safe window global extension for E2E injection
|
||||
@@ -530,23 +558,25 @@ window.__TEST_AUTH_STORE__ = mockStoreHook;
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
|
||||
// Pattern 2: Selector (optimized for performance)
|
||||
const user = useAuth(state => state.user);
|
||||
const user = useAuth((state) => state.user);
|
||||
```
|
||||
|
||||
**Why Polymorphic?**
|
||||
|
||||
- Simple pattern for most use cases
|
||||
- Optimized pattern available when needed
|
||||
- Type-safe with function overloads
|
||||
- No performance overhead
|
||||
|
||||
**Critical Implementation Detail:**
|
||||
|
||||
```typescript
|
||||
export function useAuth(): AuthState;
|
||||
export function useAuth<T>(selector: (state: AuthState) => T): T;
|
||||
export function useAuth<T>(selector?: (state: AuthState) => T): AuthState | T {
|
||||
const storeHook = useContext(AuthContext);
|
||||
if (!storeHook) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
// CRITICAL: Call the hook internally (follows React Rules of Hooks)
|
||||
return selector ? storeHook(selector) : storeHook();
|
||||
@@ -580,6 +610,7 @@ function MyComponent() {
|
||||
```
|
||||
|
||||
**Why?**
|
||||
|
||||
- Component re-renders when auth state changes
|
||||
- Type-safe access to all state properties
|
||||
- Clean, idiomatic React code
|
||||
@@ -605,6 +636,7 @@ export function useLogin() {
|
||||
```
|
||||
|
||||
**Why?**
|
||||
|
||||
- Event handlers run outside React render cycle
|
||||
- Don't need to re-render when state changes
|
||||
- Using `getState()` directly is cleaner
|
||||
@@ -694,6 +726,7 @@ test.describe('Protected Pages', () => {
|
||||
```
|
||||
|
||||
**Why This Order?**
|
||||
|
||||
- AuthProvider must wrap AuthInitializer (AuthInitializer uses auth state)
|
||||
- AuthProvider should wrap all app providers (auth available everywhere)
|
||||
- Keep provider tree shallow for performance
|
||||
@@ -701,15 +734,18 @@ test.describe('Protected Pages', () => {
|
||||
### 6.6 Token Management Strategy
|
||||
|
||||
**Two-Token System:**
|
||||
|
||||
- **Access Token**: Short-lived (15 min), stored in memory/sessionStorage
|
||||
- **Refresh Token**: Long-lived (7 days), stored in httpOnly cookie (preferred) or localStorage
|
||||
|
||||
**Token Storage Decision:**
|
||||
|
||||
- **Primary**: httpOnly cookies (most secure, prevents XSS)
|
||||
- **Fallback**: localStorage with encryption wrapper (if cookies not feasible)
|
||||
- **Access Token**: sessionStorage or React state (short-lived, acceptable risk)
|
||||
|
||||
**Token Rotation:**
|
||||
|
||||
- On refresh, both tokens are rotated
|
||||
- Old refresh token is invalidated immediately
|
||||
- Prevents token replay attacks
|
||||
@@ -717,6 +753,7 @@ test.describe('Protected Pages', () => {
|
||||
### 6.2 Per-Device Session Tracking
|
||||
|
||||
Backend tracks sessions per device:
|
||||
|
||||
- Each login creates a unique session with device info
|
||||
- Users can view all active sessions
|
||||
- Users can revoke individual sessions
|
||||
@@ -724,6 +761,7 @@ Backend tracks sessions per device:
|
||||
- "Logout All" deactivates all sessions
|
||||
|
||||
Frontend Implementation:
|
||||
|
||||
- Session list page at `/settings/sessions`
|
||||
- Display device name, IP, location, last used
|
||||
- Highlight current session
|
||||
@@ -732,6 +770,7 @@ Frontend Implementation:
|
||||
### 6.3 Auth Guard Implementation
|
||||
|
||||
**Layout-Based Protection:**
|
||||
|
||||
```typescript
|
||||
// app/(authenticated)/layout.tsx
|
||||
export default function AuthenticatedLayout({ children }) {
|
||||
@@ -746,6 +785,7 @@ export default function AuthenticatedLayout({ children }) {
|
||||
```
|
||||
|
||||
**Permission Checks:**
|
||||
|
||||
```typescript
|
||||
// app/(authenticated)/admin/layout.tsx
|
||||
export default function AdminLayout({ children }) {
|
||||
@@ -776,12 +816,14 @@ export default function AdminLayout({ children }) {
|
||||
### 7.1 OpenAPI Client Generation
|
||||
|
||||
**Workflow:**
|
||||
|
||||
```
|
||||
Backend OpenAPI Spec → @hey-api/openapi-ts → TypeScript Client
|
||||
(/api/v1/openapi.json) (src/lib/api/generated/)
|
||||
```
|
||||
|
||||
**Generation Script** (`scripts/generate-api-client.sh`):
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
API_URL="${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8000}"
|
||||
@@ -792,6 +834,7 @@ npx @hey-api/openapi-ts \
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Type-safe API calls
|
||||
- Auto-completion in IDE
|
||||
- Compile-time error checking
|
||||
@@ -801,6 +844,7 @@ npx @hey-api/openapi-ts \
|
||||
### 7.2 Axios Configuration
|
||||
|
||||
**Base Instance** (`src/lib/api/client.ts`):
|
||||
|
||||
```typescript
|
||||
export const apiClient = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
||||
@@ -812,6 +856,7 @@ export const apiClient = axios.create({
|
||||
```
|
||||
|
||||
**Request Interceptor:**
|
||||
|
||||
```typescript
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
@@ -826,6 +871,7 @@ apiClient.interceptors.request.use(
|
||||
```
|
||||
|
||||
**Response Interceptor:**
|
||||
|
||||
```typescript
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
@@ -850,6 +896,7 @@ apiClient.interceptors.response.use(
|
||||
### 7.3 Error Handling
|
||||
|
||||
**Backend Error Format:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: false,
|
||||
@@ -864,24 +911,28 @@ apiClient.interceptors.response.use(
|
||||
```
|
||||
|
||||
**Frontend Error Parsing:**
|
||||
|
||||
```typescript
|
||||
export function parseAPIError(error: AxiosError): APIError {
|
||||
if (error.response?.data?.errors) {
|
||||
return error.response.data.errors;
|
||||
}
|
||||
return [{
|
||||
code: 'UNKNOWN',
|
||||
message: 'An unexpected error occurred'
|
||||
}];
|
||||
return [
|
||||
{
|
||||
code: 'UNKNOWN',
|
||||
message: 'An unexpected error occurred',
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**Error Code Mapping:**
|
||||
|
||||
```typescript
|
||||
const ERROR_MESSAGES = {
|
||||
'AUTH_001': 'Invalid email or password',
|
||||
'USER_002': 'This email is already registered',
|
||||
'VAL_001': 'Please check your input',
|
||||
AUTH_001: 'Invalid email or password',
|
||||
USER_002: 'This email is already registered',
|
||||
VAL_001: 'Please check your input',
|
||||
// ... all backend error codes
|
||||
};
|
||||
```
|
||||
@@ -889,6 +940,7 @@ const ERROR_MESSAGES = {
|
||||
### 7.4 React Query Hooks Pattern
|
||||
|
||||
**Standard Pattern:**
|
||||
|
||||
```typescript
|
||||
// lib/api/hooks/useUsers.ts
|
||||
export function useUsers(filters?: UserFilters) {
|
||||
@@ -955,6 +1007,7 @@ app/
|
||||
```
|
||||
|
||||
**Route Groups** (parentheses in folder name):
|
||||
|
||||
- Organize routes without affecting URL
|
||||
- Apply different layouts to route subsets
|
||||
- Example: `(auth)` and `(authenticated)` have different layouts
|
||||
@@ -962,23 +1015,27 @@ app/
|
||||
### 8.2 Layout Strategy
|
||||
|
||||
**Root Layout** (`app/layout.tsx`):
|
||||
|
||||
- HTML structure
|
||||
- React Query provider
|
||||
- Theme provider
|
||||
- Global metadata
|
||||
|
||||
**Auth Layout** (`app/(auth)/layout.tsx`):
|
||||
|
||||
- Centered form container
|
||||
- No header/footer
|
||||
- Minimal styling
|
||||
|
||||
**Authenticated Layout** (`app/(authenticated)/layout.tsx`):
|
||||
|
||||
- Auth guard (redirect if not authenticated)
|
||||
- Header with user menu
|
||||
- Main content area
|
||||
- Footer
|
||||
|
||||
**Admin Layout** (`app/(authenticated)/admin/layout.tsx`):
|
||||
|
||||
- Admin sidebar
|
||||
- Breadcrumbs
|
||||
- Admin permission check (is_superuser)
|
||||
@@ -1036,11 +1093,13 @@ components/
|
||||
### 9.2 Component Guidelines
|
||||
|
||||
**Naming:**
|
||||
|
||||
- PascalCase for components: `UserTable.tsx`
|
||||
- Match file name with component name
|
||||
- One component per file
|
||||
|
||||
**Structure:**
|
||||
|
||||
```typescript
|
||||
// 1. Imports
|
||||
import { useState } from 'react';
|
||||
@@ -1078,6 +1137,7 @@ export function UserTable({ filters }: UserTableProps) {
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
|
||||
- Prefer named exports over default exports
|
||||
- Destructure props in function signature
|
||||
- Extract complex logic to hooks
|
||||
@@ -1087,6 +1147,7 @@ export function UserTable({ filters }: UserTableProps) {
|
||||
### 9.3 Styling Strategy
|
||||
|
||||
**Tailwind Utility Classes:**
|
||||
|
||||
```typescript
|
||||
<button className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90">
|
||||
Click Me
|
||||
@@ -1094,6 +1155,7 @@ export function UserTable({ filters }: UserTableProps) {
|
||||
```
|
||||
|
||||
**Conditional Classes with cn():**
|
||||
|
||||
```typescript
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
@@ -1105,6 +1167,7 @@ import { cn } from '@/lib/utils/cn';
|
||||
```
|
||||
|
||||
**Dark Mode:**
|
||||
|
||||
```typescript
|
||||
<div className="bg-white dark:bg-gray-900 text-black dark:text-white">
|
||||
Content
|
||||
@@ -1134,18 +1197,21 @@ import { cn } from '@/lib/utils/cn';
|
||||
### 10.2 Test Categories
|
||||
|
||||
**Unit Tests** (60% of suite):
|
||||
|
||||
- Utilities (`lib/utils/`)
|
||||
- Custom hooks (`hooks/`)
|
||||
- Services (`services/`)
|
||||
- Pure functions
|
||||
|
||||
**Component Tests** (30% of suite):
|
||||
|
||||
- Reusable components (`components/`)
|
||||
- Forms with validation
|
||||
- User interactions
|
||||
- Accessibility
|
||||
|
||||
**Integration Tests** (E2E with Playwright, 10% of suite):
|
||||
|
||||
- Critical user flows:
|
||||
- Login → Dashboard
|
||||
- Admin: Create/Edit/Delete User
|
||||
@@ -1157,6 +1223,7 @@ import { cn } from '@/lib/utils/cn';
|
||||
### 10.3 Testing Tools
|
||||
|
||||
**Jest + React Testing Library:**
|
||||
|
||||
```typescript
|
||||
// UserTable.test.tsx
|
||||
import { render, screen } from '@testing-library/react';
|
||||
@@ -1169,6 +1236,7 @@ test('renders user table with data', async () => {
|
||||
```
|
||||
|
||||
**Playwright E2E:**
|
||||
|
||||
```typescript
|
||||
// tests/e2e/auth.spec.ts
|
||||
test('user can login', async ({ page }) => {
|
||||
@@ -1183,11 +1251,13 @@ test('user can login', async ({ page }) => {
|
||||
### 10.4 Coverage Target
|
||||
|
||||
**Goal: 90%+ Overall Coverage**
|
||||
|
||||
- Unit tests: 95%+
|
||||
- Component tests: 85%+
|
||||
- Integration tests: Critical paths only
|
||||
|
||||
**Justification for 90%:**
|
||||
|
||||
- This is a template for production projects
|
||||
- High coverage ensures robustness
|
||||
- Confidence for extension and customization
|
||||
@@ -1199,6 +1269,7 @@ test('user can login', async ({ page }) => {
|
||||
### 11.1 Optimization Strategies
|
||||
|
||||
**Code Splitting:**
|
||||
|
||||
```typescript
|
||||
// Dynamic imports for heavy components
|
||||
const AdminDashboard = dynamic(() => import('./AdminDashboard'), {
|
||||
@@ -1207,6 +1278,7 @@ const AdminDashboard = dynamic(() => import('./AdminDashboard'), {
|
||||
```
|
||||
|
||||
**Image Optimization:**
|
||||
|
||||
```typescript
|
||||
import Image from 'next/image';
|
||||
|
||||
@@ -1220,11 +1292,13 @@ import Image from 'next/image';
|
||||
```
|
||||
|
||||
**React Query Caching:**
|
||||
|
||||
- Stale time: 1 minute (reduce unnecessary refetches)
|
||||
- Cache time: 5 minutes (keep data in memory)
|
||||
- Background refetch: Yes (keep data fresh)
|
||||
|
||||
**Bundle Size Monitoring:**
|
||||
|
||||
```bash
|
||||
npm run build && npm run analyze
|
||||
# Use webpack-bundle-analyzer to identify large dependencies
|
||||
@@ -1233,12 +1307,14 @@ npm run build && npm run analyze
|
||||
### 11.2 Performance Targets
|
||||
|
||||
**Lighthouse Scores:**
|
||||
|
||||
- Performance: >90
|
||||
- Accessibility: 100
|
||||
- Best Practices: >90
|
||||
- SEO: >90
|
||||
|
||||
**Core Web Vitals:**
|
||||
|
||||
- LCP (Largest Contentful Paint): <2.5s
|
||||
- FID (First Input Delay): <100ms
|
||||
- CLS (Cumulative Layout Shift): <0.1
|
||||
@@ -1250,16 +1326,19 @@ npm run build && npm run analyze
|
||||
### 12.1 Client-Side Security
|
||||
|
||||
**XSS Prevention:**
|
||||
|
||||
- React's default escaping (JSX)
|
||||
- Sanitize user input if rendering HTML
|
||||
- CSP headers (configured in backend)
|
||||
|
||||
**Token Security:**
|
||||
|
||||
- Access token: sessionStorage or memory (15 min expiry mitigates risk)
|
||||
- Refresh token: httpOnly cookie (preferred) or encrypted localStorage
|
||||
- Never log tokens to console in production
|
||||
|
||||
**HTTPS Only:**
|
||||
|
||||
- All production requests over HTTPS
|
||||
- Cookies with Secure flag
|
||||
- No mixed content
|
||||
@@ -1267,11 +1346,13 @@ npm run build && npm run analyze
|
||||
### 12.2 Input Validation
|
||||
|
||||
**Client-Side Validation:**
|
||||
|
||||
- Zod schemas for all forms
|
||||
- Immediate feedback to users
|
||||
- Prevent malformed requests
|
||||
|
||||
**Remember:**
|
||||
|
||||
- Client validation is for UX
|
||||
- Backend validation is for security
|
||||
- Always trust backend, not client
|
||||
@@ -1279,12 +1360,14 @@ npm run build && npm run analyze
|
||||
### 12.3 Dependency Security
|
||||
|
||||
**Regular Audits:**
|
||||
|
||||
```bash
|
||||
npm audit
|
||||
npm audit fix
|
||||
```
|
||||
|
||||
**Automated Scanning:**
|
||||
|
||||
- Dependabot (GitHub)
|
||||
- Snyk (CI/CD integration)
|
||||
|
||||
@@ -1295,12 +1378,14 @@ npm audit fix
|
||||
### 13.1 Why Next.js App Router?
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Server Components reduce client bundle
|
||||
- Better data fetching patterns
|
||||
- Streaming and Suspense built-in
|
||||
- Simpler layouts and error handling
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Newer, less mature than Pages Router
|
||||
- Learning curve for team
|
||||
|
||||
@@ -1309,11 +1394,13 @@ npm audit fix
|
||||
### 13.2 Why TanStack Query?
|
||||
|
||||
**Alternatives Considered:**
|
||||
|
||||
- SWR: Similar but less features
|
||||
- Redux Toolkit Query: Too much boilerplate for our use case
|
||||
- Apollo Client: Overkill for REST API
|
||||
|
||||
**Why TanStack Query:**
|
||||
|
||||
- Best-in-class caching and refetching
|
||||
- Framework-agnostic (not tied to Next.js)
|
||||
- Excellent DevTools
|
||||
@@ -1322,11 +1409,13 @@ npm audit fix
|
||||
### 13.3 Why Zustand over Redux?
|
||||
|
||||
**Why NOT Redux:**
|
||||
|
||||
- Too much boilerplate (actions, reducers, middleware)
|
||||
- We don't need time-travel debugging
|
||||
- Most state is server state (handled by React Query)
|
||||
|
||||
**Why Zustand:**
|
||||
|
||||
- Minimal API (easy to learn)
|
||||
- No Context API overhead
|
||||
- Can use outside React (interceptors)
|
||||
@@ -1335,11 +1424,13 @@ npm audit fix
|
||||
### 13.4 Why shadcn/ui over Component Libraries?
|
||||
|
||||
**Alternatives Considered:**
|
||||
|
||||
- Material-UI: Heavy, opinionated styling
|
||||
- Chakra UI: Good, but still an npm dependency
|
||||
- Ant Design: Too opinionated for template
|
||||
|
||||
**Why shadcn/ui:**
|
||||
|
||||
- Copy-paste (full control)
|
||||
- Accessible (Radix UI primitives)
|
||||
- Tailwind-based (consistent with our stack)
|
||||
@@ -1348,11 +1439,13 @@ npm audit fix
|
||||
### 13.5 Why Axios over Fetch?
|
||||
|
||||
**Why NOT Fetch:**
|
||||
|
||||
- No request/response interceptors
|
||||
- Manual timeout handling
|
||||
- Less ergonomic error handling
|
||||
|
||||
**Why Axios:**
|
||||
|
||||
- Interceptors (essential for auth)
|
||||
- Automatic JSON parsing
|
||||
- Better error handling
|
||||
@@ -1364,11 +1457,13 @@ npm audit fix
|
||||
**Decision: httpOnly Cookies (Primary), localStorage (Fallback)**
|
||||
|
||||
**Why httpOnly Cookies:**
|
||||
|
||||
- Most secure (not accessible to JavaScript)
|
||||
- Prevents XSS token theft
|
||||
- Automatic sending with requests (if CORS configured)
|
||||
|
||||
**Why Fallback to localStorage:**
|
||||
|
||||
- Simpler initial setup (no backend cookie handling)
|
||||
- Still secure with proper measures:
|
||||
- Short access token expiry (15 min)
|
||||
@@ -1377,6 +1472,7 @@ npm audit fix
|
||||
- Encrypted wrapper (optional)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- Try httpOnly cookies first
|
||||
- Fall back to localStorage if not feasible
|
||||
- Document choice in code
|
||||
@@ -1388,12 +1484,14 @@ npm audit fix
|
||||
### 14.1 Production Deployment
|
||||
|
||||
**Recommended Platform: Vercel**
|
||||
|
||||
- Native Next.js support
|
||||
- Edge functions for middleware
|
||||
- Automatic preview deployments
|
||||
- CDN with global edge network
|
||||
|
||||
**Alternative: Docker**
|
||||
|
||||
```dockerfile
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
@@ -1408,18 +1506,21 @@ CMD ["npm", "start"]
|
||||
### 14.2 Environment Configuration
|
||||
|
||||
**Development:**
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
**Production:**
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
|
||||
NODE_ENV=production
|
||||
```
|
||||
|
||||
**Secrets:**
|
||||
|
||||
- Never commit `.env.local`
|
||||
- Use platform-specific secret management (Vercel Secrets, Docker Secrets)
|
||||
|
||||
@@ -1453,6 +1554,7 @@ jobs:
|
||||
This architecture document provides a comprehensive overview of the frontend system design, patterns, and decisions. It should serve as a reference for developers working on the project and guide future architectural decisions.
|
||||
|
||||
For specific implementation details, refer to:
|
||||
|
||||
- **CODING_STANDARDS.md**: Code style and conventions
|
||||
- **COMPONENT_GUIDE.md**: Component usage and patterns
|
||||
- **FEATURE_EXAMPLES.md**: Step-by-step feature implementation
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
### 1.1 General Principles
|
||||
|
||||
**✅ DO:**
|
||||
|
||||
- Enable strict mode in `tsconfig.json`
|
||||
- Define explicit types for all function parameters and return values
|
||||
- Use TypeScript's type inference where obvious
|
||||
@@ -38,6 +39,7 @@
|
||||
- Use generics for reusable, type-safe components and functions
|
||||
|
||||
**❌ DON'T:**
|
||||
|
||||
- Use `any` (use `unknown` if type is truly unknown)
|
||||
- Use `as any` casts (refactor to proper types)
|
||||
- Use `@ts-ignore` or `@ts-nocheck` (fix the underlying issue)
|
||||
@@ -46,6 +48,7 @@
|
||||
### 1.2 Interfaces vs Types
|
||||
|
||||
**Use `interface` for object shapes:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
interface User {
|
||||
@@ -63,6 +66,7 @@ interface UserFormProps {
|
||||
```
|
||||
|
||||
**Use `type` for unions, intersections, and primitives:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type UserRole = 'admin' | 'user' | 'guest';
|
||||
@@ -74,6 +78,7 @@ type UserWithPermissions = User & { permissions: string[] };
|
||||
### 1.3 Function Signatures
|
||||
|
||||
**Always type function parameters and return values:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
function formatUserName(user: User): string {
|
||||
@@ -86,7 +91,8 @@ async function fetchUser(id: string): Promise<User> {
|
||||
}
|
||||
|
||||
// ❌ Bad
|
||||
function formatUserName(user) { // Implicit any
|
||||
function formatUserName(user) {
|
||||
// Implicit any
|
||||
return `${user.firstName} ${user.lastName}`;
|
||||
}
|
||||
```
|
||||
@@ -94,6 +100,7 @@ function formatUserName(user) { // Implicit any
|
||||
### 1.4 Generics
|
||||
|
||||
**Use generics for reusable, type-safe code:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Generic data table
|
||||
interface DataTableProps<T> {
|
||||
@@ -113,6 +120,7 @@ export function DataTable<T>({ data, columns, onRowClick }: DataTableProps<T>) {
|
||||
### 1.5 Unknown vs Any
|
||||
|
||||
**Use `unknown` for truly unknown types:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Force type checking
|
||||
function parseJSON(jsonString: string): unknown {
|
||||
@@ -134,15 +142,11 @@ function parseJSON(jsonString: string): any {
|
||||
### 1.6 Type Guards
|
||||
|
||||
**Create type guards for runtime type checking:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
function isUser(value: unknown): value is User {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'id' in value &&
|
||||
'email' in value
|
||||
);
|
||||
return typeof value === 'object' && value !== null && 'id' in value && 'email' in value;
|
||||
}
|
||||
|
||||
// Usage
|
||||
@@ -155,6 +159,7 @@ if (isUser(data)) {
|
||||
### 1.7 Utility Types
|
||||
|
||||
**Use TypeScript utility types:**
|
||||
|
||||
```typescript
|
||||
// Partial - make all properties optional
|
||||
type UserUpdate = Partial<User>;
|
||||
@@ -182,6 +187,7 @@ type NonAdminRole = Exclude<UserRole, 'admin'>;
|
||||
### 2.1 Component Structure
|
||||
|
||||
**Standard component template:**
|
||||
|
||||
```typescript
|
||||
// 1. Imports (React, external libs, internal, types, styles)
|
||||
'use client'; // If client component
|
||||
@@ -240,6 +246,7 @@ export function UserList({
|
||||
### 2.2 Server Components vs Client Components
|
||||
|
||||
**Server Components by default:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Server Component (default)
|
||||
// app/(authenticated)/users/page.tsx
|
||||
@@ -255,6 +262,7 @@ export default async function UsersPage() {
|
||||
```
|
||||
|
||||
**Client Components only when needed:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Client Component (interactive)
|
||||
'use client';
|
||||
@@ -276,6 +284,7 @@ export function UserList() {
|
||||
```
|
||||
|
||||
**When to use Client Components:**
|
||||
|
||||
- Using React hooks (useState, useEffect, etc.)
|
||||
- Event handlers (onClick, onChange, etc.)
|
||||
- Browser APIs (window, document, localStorage)
|
||||
@@ -284,6 +293,7 @@ export function UserList() {
|
||||
### 2.3 Props
|
||||
|
||||
**Always type props explicitly:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Explicit interface
|
||||
interface ButtonProps {
|
||||
@@ -300,13 +310,14 @@ export function Button({
|
||||
onClick,
|
||||
variant = 'primary',
|
||||
disabled = false,
|
||||
className
|
||||
className,
|
||||
}: ButtonProps) {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
**Destructure props in function signature:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
function UserCard({ user, onEdit }: UserCardProps) {
|
||||
@@ -320,6 +331,7 @@ function UserCard(props: UserCardProps) {
|
||||
```
|
||||
|
||||
**Allow className override:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Allow consumers to add classes
|
||||
interface CardProps {
|
||||
@@ -339,6 +351,7 @@ export function Card({ title, className }: CardProps) {
|
||||
### 2.4 Composition Over Prop Drilling
|
||||
|
||||
**Use composition patterns:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Composition
|
||||
<Card>
|
||||
@@ -366,6 +379,7 @@ export function Card({ title, className }: CardProps) {
|
||||
### 2.5 Named Exports vs Default Exports
|
||||
|
||||
**Prefer named exports:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Named export
|
||||
export function UserList() {
|
||||
@@ -385,6 +399,7 @@ import WhateverName from './UserList';
|
||||
```
|
||||
|
||||
**Exception: Next.js pages must use default export**
|
||||
|
||||
```typescript
|
||||
// pages and route handlers require default export
|
||||
export default function UsersPage() {
|
||||
@@ -395,6 +410,7 @@ export default function UsersPage() {
|
||||
### 2.6 Custom Hooks
|
||||
|
||||
**Extract reusable logic:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Custom hook
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
@@ -425,15 +441,16 @@ function SearchInput() {
|
||||
```
|
||||
|
||||
**Hook naming: Always prefix with "use":**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
function useAuth() { }
|
||||
function useUsers() { }
|
||||
function useDebounce() { }
|
||||
function useAuth() {}
|
||||
function useUsers() {}
|
||||
function useDebounce() {}
|
||||
|
||||
// ❌ Bad
|
||||
function auth() { }
|
||||
function getUsers() { }
|
||||
function auth() {}
|
||||
function getUsers() {}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -442,20 +459,21 @@ function getUsers() { }
|
||||
|
||||
### 3.1 Files and Directories
|
||||
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Components | PascalCase | `UserTable.tsx`, `LoginForm.tsx` |
|
||||
| Hooks | camelCase with `use` prefix | `useAuth.ts`, `useDebounce.ts` |
|
||||
| Utilities | camelCase | `formatDate.ts`, `parseError.ts` |
|
||||
| Types | camelCase | `user.ts`, `api.ts` |
|
||||
| Constants | camelCase or UPPER_SNAKE_CASE | `constants.ts`, `API_ENDPOINTS.ts` |
|
||||
| Stores | camelCase with `Store` suffix | `authStore.ts`, `uiStore.ts` |
|
||||
| Services | camelCase with `Service` suffix | `authService.ts`, `adminService.ts` |
|
||||
| Pages (Next.js) | lowercase | `page.tsx`, `layout.tsx`, `loading.tsx` |
|
||||
| Type | Convention | Example |
|
||||
| --------------- | ------------------------------- | --------------------------------------- |
|
||||
| Components | PascalCase | `UserTable.tsx`, `LoginForm.tsx` |
|
||||
| Hooks | camelCase with `use` prefix | `useAuth.ts`, `useDebounce.ts` |
|
||||
| Utilities | camelCase | `formatDate.ts`, `parseError.ts` |
|
||||
| Types | camelCase | `user.ts`, `api.ts` |
|
||||
| Constants | camelCase or UPPER_SNAKE_CASE | `constants.ts`, `API_ENDPOINTS.ts` |
|
||||
| Stores | camelCase with `Store` suffix | `authStore.ts`, `uiStore.ts` |
|
||||
| Services | camelCase with `Service` suffix | `authService.ts`, `adminService.ts` |
|
||||
| Pages (Next.js) | lowercase | `page.tsx`, `layout.tsx`, `loading.tsx` |
|
||||
|
||||
### 3.2 Variables and Functions
|
||||
|
||||
**Variables:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: camelCase
|
||||
const userName = 'John';
|
||||
@@ -463,36 +481,39 @@ const isAuthenticated = true;
|
||||
const userList = [];
|
||||
|
||||
// ❌ Bad
|
||||
const UserName = 'John'; // PascalCase for variable
|
||||
const UserName = 'John'; // PascalCase for variable
|
||||
const user_name = 'John'; // snake_case
|
||||
```
|
||||
|
||||
**Functions:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: camelCase, descriptive verb + noun
|
||||
function getUserById(id: string): User { }
|
||||
function formatDate(date: Date): string { }
|
||||
function handleSubmit(data: FormData): void { }
|
||||
function getUserById(id: string): User {}
|
||||
function formatDate(date: Date): string {}
|
||||
function handleSubmit(data: FormData): void {}
|
||||
|
||||
// ❌ Bad
|
||||
function User(id: string) { } // Looks like a class
|
||||
function get_user(id: string) { } // snake_case
|
||||
function gub(id: string) { } // Not descriptive
|
||||
function User(id: string) {} // Looks like a class
|
||||
function get_user(id: string) {} // snake_case
|
||||
function gub(id: string) {} // Not descriptive
|
||||
```
|
||||
|
||||
**Event Handlers:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: handle + EventName
|
||||
const handleClick = () => { };
|
||||
const handleSubmit = () => { };
|
||||
const handleInputChange = () => { };
|
||||
const handleClick = () => {};
|
||||
const handleSubmit = () => {};
|
||||
const handleInputChange = () => {};
|
||||
|
||||
// ❌ Bad
|
||||
const onClick = () => { }; // Confusing with prop name
|
||||
const submit = () => { };
|
||||
const onClick = () => {}; // Confusing with prop name
|
||||
const submit = () => {};
|
||||
```
|
||||
|
||||
**Boolean Variables:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: is/has/should prefix
|
||||
const isLoading = true;
|
||||
@@ -508,6 +529,7 @@ const error = false;
|
||||
### 3.3 Constants
|
||||
|
||||
**Use UPPER_SNAKE_CASE for true constants:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
const MAX_RETRY_ATTEMPTS = 3;
|
||||
@@ -525,16 +547,17 @@ const USER_ROLES = {
|
||||
### 3.4 Types and Interfaces
|
||||
|
||||
**PascalCase for types and interfaces:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
interface User { }
|
||||
interface UserFormProps { }
|
||||
interface User {}
|
||||
interface UserFormProps {}
|
||||
type UserId = string;
|
||||
type UserRole = 'admin' | 'user';
|
||||
|
||||
// ❌ Bad
|
||||
interface user { }
|
||||
interface user_form_props { }
|
||||
interface user {}
|
||||
interface user_form_props {}
|
||||
type userId = string;
|
||||
```
|
||||
|
||||
@@ -545,6 +568,7 @@ type userId = string;
|
||||
### 4.1 Import Order
|
||||
|
||||
**Organize imports in this order:**
|
||||
|
||||
```typescript
|
||||
// 1. React and Next.js
|
||||
import { useState, useEffect } from 'react';
|
||||
@@ -576,6 +600,7 @@ import styles from './Component.module.css';
|
||||
### 4.2 Co-location
|
||||
|
||||
**Group related files together:**
|
||||
|
||||
```
|
||||
components/admin/
|
||||
├── UserTable/
|
||||
@@ -587,6 +612,7 @@ components/admin/
|
||||
```
|
||||
|
||||
**Or flat structure for simpler components:**
|
||||
|
||||
```
|
||||
components/admin/
|
||||
├── UserTable.tsx
|
||||
@@ -598,6 +624,7 @@ components/admin/
|
||||
### 4.3 Barrel Exports
|
||||
|
||||
**Use index.ts for clean imports:**
|
||||
|
||||
```typescript
|
||||
// components/ui/index.ts
|
||||
export { Button } from './button';
|
||||
@@ -615,6 +642,7 @@ import { Button, Card, Input } from '@/components/ui';
|
||||
### 5.1 State Placement
|
||||
|
||||
**Keep state as local as possible:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Local state
|
||||
function UserFilter() {
|
||||
@@ -629,6 +657,7 @@ function UserFilter() {
|
||||
### 5.2 TanStack Query Usage
|
||||
|
||||
**Standard query pattern:**
|
||||
|
||||
```typescript
|
||||
// lib/api/hooks/useUsers.ts
|
||||
export function useUsers(filters?: UserFilters) {
|
||||
@@ -651,6 +680,7 @@ function UserList() {
|
||||
```
|
||||
|
||||
**Standard mutation pattern:**
|
||||
|
||||
```typescript
|
||||
// lib/api/hooks/useUsers.ts
|
||||
export function useUpdateUser() {
|
||||
@@ -691,22 +721,21 @@ function UserForm({ userId }: { userId: string }) {
|
||||
```
|
||||
|
||||
**Query key structure:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Consistent query keys
|
||||
['users'] // List all
|
||||
['users', userId] // Single user
|
||||
['users', { search: 'john', page: 1 }] // Filtered list
|
||||
['organizations', orgId, 'members'] // Nested resource
|
||||
|
||||
// ❌ Bad: Inconsistent
|
||||
['userList']
|
||||
['user-' + userId]
|
||||
['getUsersBySearch', 'john']
|
||||
['users'][('users', userId)][('users', { search: 'john', page: 1 })][ // List all // Single user // Filtered list
|
||||
('organizations', orgId, 'members')
|
||||
][ // Nested resource
|
||||
// ❌ Bad: Inconsistent
|
||||
'userList'
|
||||
]['user-' + userId][('getUsersBySearch', 'john')];
|
||||
```
|
||||
|
||||
### 5.3 Zustand Store Pattern
|
||||
|
||||
**Auth store example:**
|
||||
|
||||
```typescript
|
||||
// stores/authStore.ts
|
||||
import { create } from 'zustand';
|
||||
@@ -757,6 +786,7 @@ function UserAvatar() {
|
||||
```
|
||||
|
||||
**UI store example:**
|
||||
|
||||
```typescript
|
||||
// stores/uiStore.ts
|
||||
interface UIStore {
|
||||
@@ -790,6 +820,7 @@ export const useUIStore = create<UIStore>()(
|
||||
### 6.1 API Client Structure
|
||||
|
||||
**Axios instance configuration:**
|
||||
|
||||
```typescript
|
||||
// lib/api/client.ts
|
||||
import axios from 'axios';
|
||||
@@ -837,6 +868,7 @@ apiClient.interceptors.response.use(
|
||||
### 6.2 Error Handling
|
||||
|
||||
**Parse API errors:**
|
||||
|
||||
```typescript
|
||||
// lib/api/errors.ts
|
||||
export interface APIError {
|
||||
@@ -850,18 +882,20 @@ export function parseAPIError(error: AxiosError): APIError[] {
|
||||
return error.response.data.errors;
|
||||
}
|
||||
|
||||
return [{
|
||||
code: 'UNKNOWN',
|
||||
message: error.message || 'An unexpected error occurred',
|
||||
}];
|
||||
return [
|
||||
{
|
||||
code: 'UNKNOWN',
|
||||
message: error.message || 'An unexpected error occurred',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Error code mapping
|
||||
export const ERROR_MESSAGES: Record<string, string> = {
|
||||
'AUTH_001': 'Invalid email or password',
|
||||
'USER_002': 'This email is already registered',
|
||||
'VAL_001': 'Please check your input',
|
||||
'ORG_001': 'Organization name already exists',
|
||||
AUTH_001: 'Invalid email or password',
|
||||
USER_002: 'This email is already registered',
|
||||
VAL_001: 'Please check your input',
|
||||
ORG_001: 'Organization name already exists',
|
||||
};
|
||||
|
||||
export function getErrorMessage(code: string): string {
|
||||
@@ -872,18 +906,19 @@ export function getErrorMessage(code: string): string {
|
||||
### 6.3 Hook Organization
|
||||
|
||||
**One hook file per resource:**
|
||||
|
||||
```typescript
|
||||
// lib/api/hooks/useUsers.ts
|
||||
export function useUsers(filters?: UserFilters) { }
|
||||
export function useUser(userId: string) { }
|
||||
export function useCreateUser() { }
|
||||
export function useUpdateUser() { }
|
||||
export function useDeleteUser() { }
|
||||
export function useUsers(filters?: UserFilters) {}
|
||||
export function useUser(userId: string) {}
|
||||
export function useCreateUser() {}
|
||||
export function useUpdateUser() {}
|
||||
export function useDeleteUser() {}
|
||||
|
||||
// lib/api/hooks/useOrganizations.ts
|
||||
export function useOrganizations() { }
|
||||
export function useOrganization(orgId: string) { }
|
||||
export function useCreateOrganization() { }
|
||||
export function useOrganizations() {}
|
||||
export function useOrganization(orgId: string) {}
|
||||
export function useCreateOrganization() {}
|
||||
// ...
|
||||
```
|
||||
|
||||
@@ -894,6 +929,7 @@ export function useCreateOrganization() { }
|
||||
### 7.1 Form Pattern with react-hook-form + Zod
|
||||
|
||||
**Standard form implementation:**
|
||||
|
||||
```typescript
|
||||
// components/auth/LoginForm.tsx
|
||||
'use client';
|
||||
@@ -961,30 +997,34 @@ export function LoginForm() {
|
||||
### 7.2 Form Validation
|
||||
|
||||
**Complex validation with Zod:**
|
||||
|
||||
```typescript
|
||||
const userSchema = z.object({
|
||||
email: z.string().email('Invalid email'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Min 8 characters')
|
||||
.regex(/[A-Z]/, 'Must contain uppercase')
|
||||
.regex(/[0-9]/, 'Must contain number'),
|
||||
confirmPassword: z.string(),
|
||||
firstName: z.string().min(1, 'Required'),
|
||||
lastName: z.string().optional(),
|
||||
phoneNumber: z
|
||||
.string()
|
||||
.regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number')
|
||||
.optional(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
const userSchema = z
|
||||
.object({
|
||||
email: z.string().email('Invalid email'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Min 8 characters')
|
||||
.regex(/[A-Z]/, 'Must contain uppercase')
|
||||
.regex(/[0-9]/, 'Must contain number'),
|
||||
confirmPassword: z.string(),
|
||||
firstName: z.string().min(1, 'Required'),
|
||||
lastName: z.string().optional(),
|
||||
phoneNumber: z
|
||||
.string()
|
||||
.regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number')
|
||||
.optional(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
```
|
||||
|
||||
### 7.3 Form Accessibility
|
||||
|
||||
**Always include labels and error messages:**
|
||||
|
||||
```typescript
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
@@ -1009,6 +1049,7 @@ const userSchema = z.object({
|
||||
### 8.1 Tailwind CSS Usage
|
||||
|
||||
**Use utility classes:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
<button className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90">
|
||||
@@ -1022,6 +1063,7 @@ const userSchema = z.object({
|
||||
```
|
||||
|
||||
**Use cn() for conditional classes:**
|
||||
|
||||
```typescript
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
@@ -1036,6 +1078,7 @@ import { cn } from '@/lib/utils/cn';
|
||||
### 8.2 Responsive Design
|
||||
|
||||
**Mobile-first approach:**
|
||||
|
||||
```typescript
|
||||
<div className="
|
||||
w-full p-4 /* Mobile */
|
||||
@@ -1050,6 +1093,7 @@ import { cn } from '@/lib/utils/cn';
|
||||
### 8.3 Dark Mode
|
||||
|
||||
**Use dark mode classes:**
|
||||
|
||||
```typescript
|
||||
<div className="
|
||||
bg-white text-black
|
||||
@@ -1079,6 +1123,7 @@ src/
|
||||
### 10.2 Test Naming
|
||||
|
||||
**Use descriptive test names:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
test('displays user list when data is loaded', async () => {});
|
||||
@@ -1095,6 +1140,7 @@ test('renders', () => {});
|
||||
### 10.3 Component Testing
|
||||
|
||||
**Test user interactions, not implementation:**
|
||||
|
||||
```typescript
|
||||
// UserTable.test.tsx
|
||||
import { render, screen, userEvent } from '@testing-library/react';
|
||||
@@ -1115,6 +1161,7 @@ test('allows user to search for users', async () => {
|
||||
### 10.4 Accessibility Testing
|
||||
|
||||
**Test with accessibility queries:**
|
||||
|
||||
```typescript
|
||||
// Prefer getByRole over getByTestId
|
||||
const button = screen.getByRole('button', { name: 'Submit' });
|
||||
@@ -1156,6 +1203,7 @@ const textbox = screen.getByRole('textbox', { name: 'Email' });
|
||||
### 11.2 ARIA Labels
|
||||
|
||||
**Use ARIA when semantic HTML isn't enough:**
|
||||
|
||||
```typescript
|
||||
<button aria-label="Close dialog">
|
||||
<X className="w-4 h-4" />
|
||||
@@ -1169,6 +1217,7 @@ const textbox = screen.getByRole('textbox', { name: 'Email' });
|
||||
### 11.3 Keyboard Navigation
|
||||
|
||||
**Ensure all interactive elements are keyboard accessible:**
|
||||
|
||||
```typescript
|
||||
<div
|
||||
role="button"
|
||||
@@ -1191,6 +1240,7 @@ const textbox = screen.getByRole('textbox', { name: 'Email' });
|
||||
### 12.1 Code Splitting
|
||||
|
||||
**Dynamic imports for heavy components:**
|
||||
|
||||
```typescript
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
@@ -1203,6 +1253,7 @@ const HeavyChart = dynamic(() => import('./HeavyChart'), {
|
||||
### 12.2 Memoization
|
||||
|
||||
**Use React.memo for expensive renders:**
|
||||
|
||||
```typescript
|
||||
export const UserCard = React.memo(function UserCard({ user }: UserCardProps) {
|
||||
return <div>{user.name}</div>;
|
||||
@@ -1210,6 +1261,7 @@ export const UserCard = React.memo(function UserCard({ user }: UserCardProps) {
|
||||
```
|
||||
|
||||
**Use useMemo for expensive calculations:**
|
||||
|
||||
```typescript
|
||||
const sortedUsers = useMemo(() => {
|
||||
return users.sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -1219,6 +1271,7 @@ const sortedUsers = useMemo(() => {
|
||||
### 12.3 Image Optimization
|
||||
|
||||
**Always use Next.js Image component:**
|
||||
|
||||
```typescript
|
||||
import Image from 'next/image';
|
||||
|
||||
@@ -1238,6 +1291,7 @@ import Image from 'next/image';
|
||||
### 13.1 Input Sanitization
|
||||
|
||||
**Never render raw HTML:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: React escapes by default
|
||||
<div>{userInput}</div>
|
||||
@@ -1249,6 +1303,7 @@ import Image from 'next/image';
|
||||
### 13.2 Environment Variables
|
||||
|
||||
**Never commit secrets:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Use env variables
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
||||
@@ -1258,6 +1313,7 @@ const apiUrl = 'https://api.example.com';
|
||||
```
|
||||
|
||||
**Public vs Private:**
|
||||
|
||||
- `NEXT_PUBLIC_*`: Exposed to browser
|
||||
- Other vars: Server-side only
|
||||
|
||||
@@ -1266,6 +1322,7 @@ const apiUrl = 'https://api.example.com';
|
||||
## 14. Code Review Checklist
|
||||
|
||||
**Before submitting PR:**
|
||||
|
||||
- [ ] All tests pass
|
||||
- [ ] No TypeScript errors
|
||||
- [ ] ESLint passes
|
||||
@@ -1285,6 +1342,7 @@ const apiUrl = 'https://api.example.com';
|
||||
These standards ensure consistency, maintainability, and quality across the codebase. Follow them rigorously, and update this document as the project evolves.
|
||||
|
||||
For specific patterns and examples, refer to:
|
||||
|
||||
- **ARCHITECTURE.md**: System design and patterns
|
||||
- **COMPONENT_GUIDE.md**: Component usage and examples
|
||||
- **FEATURE_EXAMPLES.md**: Step-by-step implementation guides
|
||||
|
||||
@@ -27,44 +27,48 @@
|
||||
### Pitfall 1.1: Returning Hook Function Instead of Calling It
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
// Custom hook that wraps Zustand
|
||||
export function useAuth() {
|
||||
const storeHook = useContext(AuthContext);
|
||||
return storeHook; // Returns the hook function itself!
|
||||
return storeHook; // Returns the hook function itself!
|
||||
}
|
||||
|
||||
// Consumer component
|
||||
function MyComponent() {
|
||||
const authHook = useAuth(); // Got the hook function
|
||||
const { user } = authHook(); // Have to call it here ❌ Rules of Hooks violation!
|
||||
const authHook = useAuth(); // Got the hook function
|
||||
const { user } = authHook(); // Have to call it here ❌ Rules of Hooks violation!
|
||||
}
|
||||
```
|
||||
|
||||
**Why It's Wrong:**
|
||||
|
||||
- Violates React Rules of Hooks (hook called conditionally/in wrong place)
|
||||
- Confusing API for consumers
|
||||
- Can't use in conditionals or callbacks safely
|
||||
- Type inference breaks
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
// Custom hook that calls the wrapped hook internally
|
||||
export function useAuth() {
|
||||
const storeHook = useContext(AuthContext);
|
||||
if (!storeHook) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return storeHook(); // Call the hook HERE, return the state
|
||||
return storeHook(); // Call the hook HERE, return the state
|
||||
}
|
||||
|
||||
// Consumer component
|
||||
function MyComponent() {
|
||||
const { user } = useAuth(); // Direct access to state ✅
|
||||
const { user } = useAuth(); // Direct access to state ✅
|
||||
}
|
||||
```
|
||||
|
||||
**✅ EVEN BETTER (Polymorphic):**
|
||||
|
||||
```typescript
|
||||
// Support both patterns
|
||||
export function useAuth(): AuthState;
|
||||
@@ -72,17 +76,18 @@ export function useAuth<T>(selector: (state: AuthState) => T): T;
|
||||
export function useAuth<T>(selector?: (state: AuthState) => T): AuthState | T {
|
||||
const storeHook = useContext(AuthContext);
|
||||
if (!storeHook) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return selector ? storeHook(selector) : storeHook();
|
||||
}
|
||||
|
||||
// Usage - both work!
|
||||
const { user } = useAuth(); // Full state
|
||||
const user = useAuth(s => s.user); // Optimized selector
|
||||
const { user } = useAuth(); // Full state
|
||||
const user = useAuth((s) => s.user); // Optimized selector
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Always call hooks internally in custom hooks**
|
||||
- Return state/values, not hook functions
|
||||
- Support selectors for performance optimization
|
||||
@@ -92,6 +97,7 @@ const user = useAuth(s => s.user); // Optimized selector
|
||||
### Pitfall 1.2: Calling Hooks Conditionally
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
function MyComponent({ showUser }) {
|
||||
if (showUser) {
|
||||
@@ -103,6 +109,7 @@ function MyComponent({ showUser }) {
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
function MyComponent({ showUser }) {
|
||||
const { user } = useAuth(); // ✅ Always call at top level
|
||||
@@ -116,6 +123,7 @@ function MyComponent({ showUser }) {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Always call hooks at the top level of your component**
|
||||
- Never call hooks inside conditionals, loops, or nested functions
|
||||
- Return early after hooks are called
|
||||
@@ -127,6 +135,7 @@ function MyComponent({ showUser }) {
|
||||
### Pitfall 2.1: Creating New Context Value on Every Render
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
@@ -139,11 +148,13 @@ export function AuthProvider({ children }) {
|
||||
```
|
||||
|
||||
**Why It's Wrong:**
|
||||
|
||||
- Every render creates a new object
|
||||
- All consumers re-render even if values unchanged
|
||||
- Performance nightmare in large apps
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
@@ -156,6 +167,7 @@ export function AuthProvider({ children }) {
|
||||
```
|
||||
|
||||
**✅ EVEN BETTER (Zustand + Context):**
|
||||
|
||||
```typescript
|
||||
export function AuthProvider({ children, store }) {
|
||||
// Zustand hook function is stable (doesn't change)
|
||||
@@ -167,6 +179,7 @@ export function AuthProvider({ children, store }) {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Use `useMemo` for Context values that are objects**
|
||||
- Or use stable references (Zustand hooks, refs)
|
||||
- Monitor re-renders with React DevTools
|
||||
@@ -176,6 +189,7 @@ export function AuthProvider({ children, store }) {
|
||||
### Pitfall 2.2: Prop Drilling Instead of Context
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
// Passing through 5 levels
|
||||
<Layout user={user}>
|
||||
@@ -190,6 +204,7 @@ export function AuthProvider({ children, store }) {
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
// Provider at top
|
||||
<AuthProvider>
|
||||
@@ -206,6 +221,7 @@ export function AuthProvider({ children, store }) {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Use Context for data needed by many components**
|
||||
- Avoid prop drilling beyond 2-3 levels
|
||||
- But don't overuse - local state is often better
|
||||
@@ -217,6 +233,7 @@ export function AuthProvider({ children, store }) {
|
||||
### Pitfall 3.1: Mixing Render State Access and Mutation Logic
|
||||
|
||||
**❌ WRONG (Mixing patterns):**
|
||||
|
||||
```typescript
|
||||
function MyComponent() {
|
||||
// Using hook for render state
|
||||
@@ -231,6 +248,7 @@ function MyComponent() {
|
||||
```
|
||||
|
||||
**✅ CORRECT (Separate patterns):**
|
||||
|
||||
```typescript
|
||||
function MyComponent() {
|
||||
// Hook for render state (subscribes to changes)
|
||||
@@ -245,12 +263,14 @@ function MyComponent() {
|
||||
```
|
||||
|
||||
**Why This Pattern?**
|
||||
|
||||
- **Render state**: Use hook → component re-renders on changes
|
||||
- **Mutations**: Use `getState()` → no subscription, no re-renders
|
||||
- **Performance**: Event handlers don't need to subscribe
|
||||
- **Clarity**: Clear distinction between read and write
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Use hooks for state that affects rendering**
|
||||
- **Use `getState()` for mutations in callbacks**
|
||||
- Don't subscribe when you don't need to
|
||||
@@ -260,6 +280,7 @@ function MyComponent() {
|
||||
### Pitfall 3.2: Not Using Selectors for Optimization
|
||||
|
||||
**❌ SUBOPTIMAL:**
|
||||
|
||||
```typescript
|
||||
function UserAvatar() {
|
||||
// Re-renders on ANY auth state change! ❌
|
||||
@@ -270,6 +291,7 @@ function UserAvatar() {
|
||||
```
|
||||
|
||||
**✅ OPTIMIZED:**
|
||||
|
||||
```typescript
|
||||
function UserAvatar() {
|
||||
// Only re-renders when user changes ✅
|
||||
@@ -280,6 +302,7 @@ function UserAvatar() {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Use selectors for components that only need subset of state**
|
||||
- Reduces unnecessary re-renders
|
||||
- Especially important in frequently updating stores
|
||||
@@ -291,13 +314,16 @@ function UserAvatar() {
|
||||
### Pitfall 4.1: Using `any` Type
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
function processUser(user: any) { // ❌ Loses all type safety
|
||||
return user.name.toUpperCase(); // No error if user.name is undefined
|
||||
function processUser(user: any) {
|
||||
// ❌ Loses all type safety
|
||||
return user.name.toUpperCase(); // No error if user.name is undefined
|
||||
}
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
function processUser(user: User | null) {
|
||||
if (!user?.name) {
|
||||
@@ -308,6 +334,7 @@ function processUser(user: User | null) {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Never use `any` - use `unknown` if type is truly unknown**
|
||||
- Define proper types for all function parameters
|
||||
- Use type guards for runtime checks
|
||||
@@ -317,15 +344,17 @@ function processUser(user: User | null) {
|
||||
### Pitfall 4.2: Implicit Types Leading to Errors
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
// No explicit return type - type inference can be wrong
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
return context; // What type is this? ❌
|
||||
return context; // What type is this? ❌
|
||||
}
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
// Explicit return type with overloads
|
||||
export function useAuth(): AuthState;
|
||||
@@ -333,13 +362,14 @@ export function useAuth<T>(selector: (state: AuthState) => T): T;
|
||||
export function useAuth<T>(selector?: (state: AuthState) => T): AuthState | T {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return selector ? context(selector) : context();
|
||||
}
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Always provide explicit return types for public APIs**
|
||||
- Use function overloads for polymorphic functions
|
||||
- Document types in JSDoc comments
|
||||
@@ -349,16 +379,19 @@ export function useAuth<T>(selector?: (state: AuthState) => T): AuthState | T {
|
||||
### Pitfall 4.3: Not Using `import type` for Type-Only Imports
|
||||
|
||||
**❌ SUBOPTIMAL:**
|
||||
|
||||
```typescript
|
||||
import { ReactNode } from 'react'; // Might be bundled even if only used for types
|
||||
import { ReactNode } from 'react'; // Might be bundled even if only used for types
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
import type { ReactNode } from 'react'; // Guaranteed to be stripped from bundle
|
||||
import type { ReactNode } from 'react'; // Guaranteed to be stripped from bundle
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Use `import type` for type-only imports**
|
||||
- Smaller bundle size
|
||||
- Clearer intent
|
||||
@@ -370,6 +403,7 @@ import type { ReactNode } from 'react'; // Guaranteed to be stripped from bundl
|
||||
### Pitfall 5.1: Forgetting Optional Chaining for Nullable Values
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
function UserProfile() {
|
||||
const { user } = useAuth();
|
||||
@@ -378,6 +412,7 @@ function UserProfile() {
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
function UserProfile() {
|
||||
const { user } = useAuth();
|
||||
@@ -397,6 +432,7 @@ function UserProfile() {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Always handle null/undefined cases**
|
||||
- Use optional chaining (`?.`) and nullish coalescing (`??`)
|
||||
- Provide fallback UI for missing data
|
||||
@@ -406,6 +442,7 @@ function UserProfile() {
|
||||
### Pitfall 5.2: Mixing Concerns in Components
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
function UserDashboard() {
|
||||
const [users, setUsers] = useState([]);
|
||||
@@ -429,6 +466,7 @@ function UserDashboard() {
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
// Custom hook for data fetching
|
||||
function useUsers() {
|
||||
@@ -460,6 +498,7 @@ function UserDashboard() {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Separate concerns: data fetching, business logic, rendering**
|
||||
- Extract logic to custom hooks
|
||||
- Keep components focused on UI
|
||||
@@ -471,6 +510,7 @@ function UserDashboard() {
|
||||
### Pitfall 6.1: Wrong Provider Order
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
// AuthInitializer outside AuthProvider ❌
|
||||
function RootLayout({ children }) {
|
||||
@@ -486,6 +526,7 @@ function RootLayout({ children }) {
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
function RootLayout({ children }) {
|
||||
return (
|
||||
@@ -500,6 +541,7 @@ function RootLayout({ children }) {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Providers must wrap components that use them**
|
||||
- Order matters when there are dependencies
|
||||
- Keep provider tree shallow (performance)
|
||||
@@ -509,6 +551,7 @@ function RootLayout({ children }) {
|
||||
### Pitfall 6.2: Creating Too Many Providers
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
// Separate provider for every piece of state ❌
|
||||
<UserProvider>
|
||||
@@ -525,6 +568,7 @@ function RootLayout({ children }) {
|
||||
```
|
||||
|
||||
**✅ BETTER:**
|
||||
|
||||
```typescript
|
||||
// Combine related state, use Zustand for most things
|
||||
<AuthProvider> {/* Only for auth DI */}
|
||||
@@ -541,6 +585,7 @@ const useUserPreferences = create(...); // User settings
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Use Context only when necessary** (DI, third-party integrations)
|
||||
- **Use Zustand for most global state** (no provider needed)
|
||||
- Avoid provider hell
|
||||
@@ -552,6 +597,7 @@ const useUserPreferences = create(...); // User settings
|
||||
### Pitfall 7.1: Using Hooks in Event Handlers
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
function MyComponent() {
|
||||
const handleClick = () => {
|
||||
@@ -564,6 +610,7 @@ function MyComponent() {
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
function MyComponent() {
|
||||
const { user } = useAuth(); // ✅ Hook at component top level
|
||||
@@ -587,6 +634,7 @@ function MyComponent() {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Never call hooks inside event handlers**
|
||||
- For render state: Call hook at top level, access in closure
|
||||
- For mutations: Use `store.getState().method()`
|
||||
@@ -596,13 +644,15 @@ function MyComponent() {
|
||||
### Pitfall 7.2: Not Handling Async Errors in Event Handlers
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
const handleSubmit = async (data: FormData) => {
|
||||
await apiCall(data); // ❌ No error handling!
|
||||
await apiCall(data); // ❌ No error handling!
|
||||
};
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
const handleSubmit = async (data: FormData) => {
|
||||
try {
|
||||
@@ -616,6 +666,7 @@ const handleSubmit = async (data: FormData) => {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Always wrap async calls in try/catch**
|
||||
- Provide user feedback for both success and errors
|
||||
- Log errors for debugging
|
||||
@@ -627,6 +678,7 @@ const handleSubmit = async (data: FormData) => {
|
||||
### Pitfall 8.1: Not Mocking Context Providers in Tests
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
// Test without provider ❌
|
||||
test('renders user name', () => {
|
||||
@@ -636,6 +688,7 @@ test('renders user name', () => {
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
// Mock the hook
|
||||
jest.mock('@/lib/stores', () => ({
|
||||
@@ -654,6 +707,7 @@ test('renders user name', () => {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Mock hooks at module level in tests**
|
||||
- Provide necessary return values for each test case
|
||||
- Test both success and error states
|
||||
@@ -663,6 +717,7 @@ test('renders user name', () => {
|
||||
### Pitfall 8.2: Testing Implementation Details
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
test('calls useAuthStore hook', () => {
|
||||
const spy = jest.spyOn(require('@/lib/stores'), 'useAuthStore');
|
||||
@@ -672,6 +727,7 @@ test('calls useAuthStore hook', () => {
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
test('displays user name when authenticated', () => {
|
||||
(useAuth as jest.Mock).mockReturnValue({
|
||||
@@ -685,6 +741,7 @@ test('displays user name when authenticated', () => {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Test behavior, not implementation**
|
||||
- Focus on what the user sees/does
|
||||
- Don't test internal API calls unless critical
|
||||
@@ -696,6 +753,7 @@ test('displays user name when authenticated', () => {
|
||||
### Pitfall 9.1: Not Using React.memo for Expensive Components
|
||||
|
||||
**❌ SUBOPTIMAL:**
|
||||
|
||||
```typescript
|
||||
// Re-renders every time parent re-renders ❌
|
||||
function ExpensiveChart({ data }) {
|
||||
@@ -705,6 +763,7 @@ function ExpensiveChart({ data }) {
|
||||
```
|
||||
|
||||
**✅ OPTIMIZED:**
|
||||
|
||||
```typescript
|
||||
// Only re-renders when data changes ✅
|
||||
export const ExpensiveChart = React.memo(function ExpensiveChart({ data }) {
|
||||
@@ -713,6 +772,7 @@ export const ExpensiveChart = React.memo(function ExpensiveChart({ data }) {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Use `React.memo` for expensive components**
|
||||
- Especially useful for list items, charts, heavy UI
|
||||
- Profile with React DevTools to identify candidates
|
||||
@@ -722,6 +782,7 @@ export const ExpensiveChart = React.memo(function ExpensiveChart({ data }) {
|
||||
### Pitfall 9.2: Creating Functions Inside Render
|
||||
|
||||
**❌ SUBOPTIMAL:**
|
||||
|
||||
```typescript
|
||||
function MyComponent() {
|
||||
return (
|
||||
@@ -733,6 +794,7 @@ function MyComponent() {
|
||||
```
|
||||
|
||||
**✅ OPTIMIZED:**
|
||||
|
||||
```typescript
|
||||
function MyComponent() {
|
||||
const handleClick = useCallback(() => {
|
||||
@@ -744,15 +806,18 @@ function MyComponent() {
|
||||
```
|
||||
|
||||
**When to Optimize:**
|
||||
|
||||
- **For memoized child components** (memo, PureComponent)
|
||||
- **For expensive event handlers**
|
||||
- **When profiling shows performance issues**
|
||||
|
||||
**When NOT to optimize:**
|
||||
|
||||
- **Simple components with cheap operations** (premature optimization)
|
||||
- **One-off event handlers**
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Use `useCallback` for functions passed to memoized children**
|
||||
- But don't optimize everything - profile first
|
||||
|
||||
@@ -763,6 +828,7 @@ function MyComponent() {
|
||||
### Pitfall 10.1: Not Using Barrel Exports
|
||||
|
||||
**❌ INCONSISTENT:**
|
||||
|
||||
```typescript
|
||||
// Deep imports all over the codebase
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
@@ -771,6 +837,7 @@ import { User } from '@/lib/stores/authStore';
|
||||
```
|
||||
|
||||
**✅ CONSISTENT:**
|
||||
|
||||
```typescript
|
||||
// Barrel exports in stores/index.ts
|
||||
export { useAuth, AuthProvider } from '../auth/AuthContext';
|
||||
@@ -781,6 +848,7 @@ import { useAuth, useAuthStore, User } from '@/lib/stores';
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Create barrel exports (index.ts) for public APIs**
|
||||
- Easier to refactor internal structure
|
||||
- Consistent import paths across codebase
|
||||
@@ -790,31 +858,44 @@ import { useAuth, useAuthStore, User } from '@/lib/stores';
|
||||
### Pitfall 10.2: Circular Dependencies
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
// fileA.ts
|
||||
import { functionB } from './fileB';
|
||||
export function functionA() { return functionB(); }
|
||||
export function functionA() {
|
||||
return functionB();
|
||||
}
|
||||
|
||||
// fileB.ts
|
||||
import { functionA } from './fileA'; // ❌ Circular!
|
||||
export function functionB() { return functionA(); }
|
||||
import { functionA } from './fileA'; // ❌ Circular!
|
||||
export function functionB() {
|
||||
return functionA();
|
||||
}
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
// utils.ts
|
||||
export function sharedFunction() { /* shared logic */ }
|
||||
export function sharedFunction() {
|
||||
/* shared logic */
|
||||
}
|
||||
|
||||
// fileA.ts
|
||||
import { sharedFunction } from './utils';
|
||||
export function functionA() { return sharedFunction(); }
|
||||
export function functionA() {
|
||||
return sharedFunction();
|
||||
}
|
||||
|
||||
// fileB.ts
|
||||
import { sharedFunction } from './utils';
|
||||
export function functionB() { return sharedFunction(); }
|
||||
export function functionB() {
|
||||
return sharedFunction();
|
||||
}
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Avoid circular imports**
|
||||
- Extract shared code to separate modules
|
||||
- Keep dependency graph acyclic
|
||||
@@ -840,6 +921,7 @@ npm run build
|
||||
```
|
||||
|
||||
**In browser:**
|
||||
|
||||
- [ ] No console errors or warnings
|
||||
- [ ] Components render correctly
|
||||
- [ ] No infinite loops or excessive re-renders (React DevTools)
|
||||
|
||||
@@ -93,12 +93,14 @@ npm run coverage:view
|
||||
### Pros & Cons
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Native browser coverage (most accurate)
|
||||
- ✅ No build instrumentation needed (faster)
|
||||
- ✅ Works with source maps
|
||||
- ✅ Zero performance overhead
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Chromium only (V8 engine specific)
|
||||
- ❌ Requires v8-to-istanbul conversion
|
||||
|
||||
@@ -168,11 +170,13 @@ This generates: `coverage-combined/index.html`
|
||||
### Pros & Cons
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Works on all browsers (Firefox, Safari, etc.)
|
||||
- ✅ Industry standard tooling
|
||||
- ✅ No conversion needed
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Requires code instrumentation (slower builds)
|
||||
- ❌ More complex setup
|
||||
- ❌ Slight test performance overhead
|
||||
@@ -196,11 +200,9 @@ module.exports = {
|
||||
presets: ['next/babel'],
|
||||
env: {
|
||||
test: {
|
||||
plugins: [
|
||||
process.env.E2E_COVERAGE && 'istanbul'
|
||||
].filter(Boolean)
|
||||
}
|
||||
}
|
||||
plugins: [process.env.E2E_COVERAGE && 'istanbul'].filter(Boolean),
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
@@ -442,12 +444,14 @@ Add to your CI pipeline (e.g., `.github/workflows/test.yml`):
|
||||
### Problem: No coverage files generated
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
```bash
|
||||
npm run coverage:convert
|
||||
# ❌ No V8 coverage found at: coverage-e2e/raw
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Verify `E2E_COVERAGE=true` is set when running tests
|
||||
2. Check coverage helpers are imported: `import { withCoverage } from './helpers/coverage'`
|
||||
3. Verify `beforeEach` and `afterEach` hooks are added
|
||||
@@ -456,12 +460,14 @@ npm run coverage:convert
|
||||
### Problem: V8 conversion fails
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
```bash
|
||||
npm run coverage:convert
|
||||
# ❌ v8-to-istanbul not installed
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
npm install -D v8-to-istanbul
|
||||
```
|
||||
@@ -469,6 +475,7 @@ npm install -D v8-to-istanbul
|
||||
### Problem: Coverage lower than expected
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
```
|
||||
Combined: 85% (expected 99%)
|
||||
```
|
||||
@@ -490,12 +497,14 @@ Combined: 85% (expected 99%)
|
||||
### Problem: Istanbul coverage is empty
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
```typescript
|
||||
await saveIstanbulCoverage(page, testName);
|
||||
// ⚠️ No Istanbul coverage found
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Verify `babel-plugin-istanbul` is configured
|
||||
2. Check `window.__coverage__` exists:
|
||||
```typescript
|
||||
@@ -507,12 +516,14 @@ await saveIstanbulCoverage(page, testName);
|
||||
### Problem: Merge script fails
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
```bash
|
||||
npm run coverage:merge
|
||||
# ❌ Error: Cannot find module 'istanbul-lib-coverage'
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
npm install -D istanbul-lib-coverage istanbul-lib-report istanbul-reports
|
||||
```
|
||||
@@ -524,11 +535,13 @@ npm install -D istanbul-lib-coverage istanbul-lib-report istanbul-reports
|
||||
### Q: Should I use V8 or Istanbul coverage?
|
||||
|
||||
**A: V8 coverage (Approach 1)** if:
|
||||
|
||||
- ✅ You only test in Chromium
|
||||
- ✅ You want zero instrumentation overhead
|
||||
- ✅ You want the most accurate coverage
|
||||
|
||||
**Istanbul (Approach 2)** if:
|
||||
|
||||
- ✅ You need cross-browser coverage
|
||||
- ✅ You already use Istanbul tooling
|
||||
- ✅ You need complex coverage transformations
|
||||
@@ -545,6 +558,7 @@ npm install -D istanbul-lib-coverage istanbul-lib-report istanbul-reports
|
||||
### Q: Can I run coverage only for specific tests?
|
||||
|
||||
**Yes:**
|
||||
|
||||
```bash
|
||||
# Single file
|
||||
E2E_COVERAGE=true npx playwright test homepage.spec.ts
|
||||
@@ -559,10 +573,7 @@ Edit `.nycrc.json` and add to `exclude` array:
|
||||
|
||||
```json
|
||||
{
|
||||
"exclude": [
|
||||
"src/app/dev/**",
|
||||
"src/lib/utils/debug.ts"
|
||||
]
|
||||
"exclude": ["src/app/dev/**", "src/lib/utils/debug.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -571,6 +582,7 @@ Edit `.nycrc.json` and add to `exclude` array:
|
||||
Not directly in the HTML report, but you can:
|
||||
|
||||
1. Generate separate reports:
|
||||
|
||||
```bash
|
||||
npx nyc report --reporter=html --report-dir=coverage-unit --temp-dir=coverage/.nyc_output
|
||||
npx nyc report --reporter=html --report-dir=coverage-e2e-only --temp-dir=coverage-e2e/.nyc_output
|
||||
@@ -581,6 +593,7 @@ Not directly in the HTML report, but you can:
|
||||
### Q: What's the performance impact on CI?
|
||||
|
||||
Typical impact:
|
||||
|
||||
- V8 coverage: +2-3 minutes (conversion time)
|
||||
- Istanbul coverage: +5-7 minutes (build instrumentation)
|
||||
- Merge step: ~10 seconds
|
||||
@@ -594,6 +607,7 @@ Total CI time increase: **3-8 minutes**
|
||||
### After Phase 1 (Infrastructure - DONE ✅)
|
||||
|
||||
You've completed:
|
||||
|
||||
- ✅ `.nycrc.json` configuration
|
||||
- ✅ Merge script (`scripts/merge-coverage.ts`)
|
||||
- ✅ Conversion script (`scripts/convert-v8-to-istanbul.ts`)
|
||||
@@ -603,6 +617,7 @@ You've completed:
|
||||
### Phase 2: Activation (When Ready)
|
||||
|
||||
1. **Install dependencies:**
|
||||
|
||||
```bash
|
||||
npm install -D v8-to-istanbul istanbul-lib-coverage istanbul-lib-report istanbul-reports
|
||||
```
|
||||
|
||||
@@ -6,20 +6,24 @@
|
||||
## Bottleneck Analysis
|
||||
|
||||
### 1. Authentication Overhead (HIGHEST IMPACT)
|
||||
|
||||
**Problem**: Each test logs in fresh via UI
|
||||
|
||||
- **Impact**: 5-7s per test × 133 admin tests = ~700s wasted
|
||||
- **Root Cause**: Using `loginViaUI(page)` in every `beforeEach`
|
||||
|
||||
**Example of current slow pattern:**
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
await loginViaUI(page); // ← 5-7s UI login EVERY test
|
||||
await loginViaUI(page); // ← 5-7s UI login EVERY test
|
||||
await page.goto('/admin');
|
||||
});
|
||||
```
|
||||
|
||||
**Solution: Playwright Storage State** (SAVE ~600-700s)
|
||||
|
||||
```typescript
|
||||
// auth.setup.ts - Run ONCE per worker
|
||||
import { test as setup } from '@playwright/test';
|
||||
@@ -62,7 +66,7 @@ export default defineConfig({
|
||||
// admin-users.spec.ts - NO MORE loginViaUI!
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Auth already loaded from storageState
|
||||
await page.goto('/admin/users'); // ← Direct navigation, ~1-2s
|
||||
await page.goto('/admin/users'); // ← Direct navigation, ~1-2s
|
||||
});
|
||||
```
|
||||
|
||||
@@ -71,28 +75,32 @@ test.beforeEach(async ({ page }) => {
|
||||
---
|
||||
|
||||
### 2. Redundant Navigation Tests (MEDIUM IMPACT)
|
||||
|
||||
**Problem**: Separate tests for "navigate to X" and "display X page"
|
||||
|
||||
- **Impact**: 3-5s per redundant test × ~15 tests = ~60s wasted
|
||||
|
||||
**Current slow pattern:**
|
||||
|
||||
```typescript
|
||||
test('should navigate to users page', async ({ page }) => {
|
||||
await page.goto('/admin/users'); // 3s
|
||||
await page.goto('/admin/users'); // 3s
|
||||
await expect(page).toHaveURL('/admin/users');
|
||||
await expect(page.locator('h1')).toContainText('User Management');
|
||||
});
|
||||
|
||||
test('should display user management page', async ({ page }) => {
|
||||
await page.goto('/admin/users'); // 3s DUPLICATE
|
||||
await page.goto('/admin/users'); // 3s DUPLICATE
|
||||
await expect(page.locator('h1')).toContainText('User Management');
|
||||
await expect(page.getByText(/manage users/i)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**Optimized pattern:**
|
||||
|
||||
```typescript
|
||||
test('should navigate to users page and display content', async ({ page }) => {
|
||||
await page.goto('/admin/users'); // 3s ONCE
|
||||
await page.goto('/admin/users'); // 3s ONCE
|
||||
|
||||
// Navigation assertions
|
||||
await expect(page).toHaveURL('/admin/users');
|
||||
@@ -109,18 +117,22 @@ test('should navigate to users page and display content', async ({ page }) => {
|
||||
---
|
||||
|
||||
### 3. Flaky Test Fix (CRITICAL)
|
||||
|
||||
**Problem**: Test #218 failed once, passed on retry
|
||||
|
||||
```
|
||||
Test: settings-password.spec.ts:24:7 › Password Change › should display password change form
|
||||
Failed: 12.8s → Retry passed: 8.3s
|
||||
```
|
||||
|
||||
**Root Cause Options**:
|
||||
|
||||
1. Race condition in form rendering
|
||||
2. Slow network request not properly awaited
|
||||
3. Animation/transition timing issue
|
||||
|
||||
**Investigation needed:**
|
||||
|
||||
```typescript
|
||||
// Current test (lines 24-35)
|
||||
test('should display password change form', async ({ page }) => {
|
||||
@@ -134,11 +146,12 @@ test('should display password change form', async ({ page }) => {
|
||||
```
|
||||
|
||||
**Temporary Solution: Skip until fixed**
|
||||
|
||||
```typescript
|
||||
test.skip('should display password change form', async ({ page }) => {
|
||||
// TODO: Fix race condition (issue #XXX)
|
||||
await page.goto('/settings/password');
|
||||
await page.waitForLoadState('networkidle'); // ← Add this
|
||||
await page.waitForLoadState('networkidle'); // ← Add this
|
||||
await expect(page.getByLabel(/current password/i)).toBeVisible();
|
||||
});
|
||||
```
|
||||
@@ -148,23 +161,27 @@ test.skip('should display password change form', async ({ page }) => {
|
||||
---
|
||||
|
||||
### 4. Optimize Wait Timeouts (LOW IMPACT)
|
||||
|
||||
**Problem**: Default timeout is 10s for all assertions
|
||||
|
||||
- **Impact**: Tests wait unnecessarily when elements load faster
|
||||
|
||||
**Current global timeout:**
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
timeout: 30000, // Per test
|
||||
expect: { timeout: 10000 }, // Per assertion
|
||||
timeout: 30000, // Per test
|
||||
expect: { timeout: 10000 }, // Per assertion
|
||||
});
|
||||
```
|
||||
|
||||
**Optimized for fast-loading pages:**
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
timeout: 20000, // Reduce from 30s
|
||||
expect: { timeout: 5000 }, // Reduce from 10s (most elements load <2s)
|
||||
timeout: 20000, // Reduce from 30s
|
||||
expect: { timeout: 5000 }, // Reduce from 10s (most elements load <2s)
|
||||
});
|
||||
```
|
||||
|
||||
@@ -175,6 +192,7 @@ export default defineConfig({
|
||||
## Implementation Priority
|
||||
|
||||
### Phase 1: Quick Wins (1-2 hours work)
|
||||
|
||||
1. ✅ **Skip flaky test #218** temporarily
|
||||
2. ✅ **Reduce timeout defaults** (5s for expects, 20s for tests)
|
||||
3. ✅ **Combine 5 most obvious redundant navigation tests**
|
||||
@@ -184,6 +202,7 @@ export default defineConfig({
|
||||
---
|
||||
|
||||
### Phase 2: Auth State Caching (2-4 hours work)
|
||||
|
||||
1. ✅ Create `e2e/auth.setup.ts` with storage state setup
|
||||
2. ✅ Update `playwright.config.ts` with projects + dependencies
|
||||
3. ✅ Remove `loginViaUI` from all admin test `beforeEach` hooks
|
||||
@@ -194,6 +213,7 @@ export default defineConfig({
|
||||
---
|
||||
|
||||
### Phase 3: Deep Optimization (4-8 hours work)
|
||||
|
||||
1. ✅ Investigate and fix flaky test root cause
|
||||
2. ✅ Audit all navigation tests for redundancy
|
||||
3. ✅ Combine related assertions (e.g., all stat cards in one test)
|
||||
@@ -205,12 +225,12 @@ export default defineConfig({
|
||||
|
||||
## Total Expected Improvement
|
||||
|
||||
| Phase | Time Investment | Time Saved | % Improvement |
|
||||
|-------|----------------|------------|---------------|
|
||||
| Phase 1 | 1-2 hours | ~150s | 7% |
|
||||
| Phase 2 | 2-4 hours | ~700s | 35% |
|
||||
| Phase 3 | 4-8 hours | ~200s | 10% |
|
||||
| **Total** | **7-14 hours** | **~1050s** | **50-60%** |
|
||||
| Phase | Time Investment | Time Saved | % Improvement |
|
||||
| --------- | --------------- | ---------- | ------------- |
|
||||
| Phase 1 | 1-2 hours | ~150s | 7% |
|
||||
| Phase 2 | 2-4 hours | ~700s | 35% |
|
||||
| Phase 3 | 4-8 hours | ~200s | 10% |
|
||||
| **Total** | **7-14 hours** | **~1050s** | **50-60%** |
|
||||
|
||||
**Final target**: 2100s → 1050s = **~17-18 minutes** (currently ~35 minutes)
|
||||
|
||||
@@ -219,6 +239,7 @@ export default defineConfig({
|
||||
## Risks and Considerations
|
||||
|
||||
### Storage State Caching Risks:
|
||||
|
||||
1. **Test isolation**: Shared auth state could cause cross-test pollution
|
||||
- **Mitigation**: Use separate storage files per role, clear cookies between tests
|
||||
2. **Stale auth tokens**: Mock tokens might expire
|
||||
@@ -227,6 +248,7 @@ export default defineConfig({
|
||||
- **Mitigation**: Keep `loginViaUI` tests for auth flow verification
|
||||
|
||||
### Recommended Safeguards:
|
||||
|
||||
```typescript
|
||||
// Clear non-auth state between tests
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -249,15 +271,18 @@ test.beforeEach(async ({ page }) => {
|
||||
## Next Steps
|
||||
|
||||
**Immediate Actions (Do Now):**
|
||||
|
||||
1. Skip flaky test #218 with TODO comment
|
||||
2. Reduce timeout defaults in playwright.config.ts
|
||||
3. Create this optimization plan issue/ticket
|
||||
|
||||
**Short-term (This Week):**
|
||||
|
||||
1. Implement auth storage state (Phase 2)
|
||||
2. Combine obvious redundant tests (Phase 1)
|
||||
|
||||
**Medium-term (Next Sprint):**
|
||||
|
||||
1. Investigate flaky test root cause
|
||||
2. Audit all tests for redundancy
|
||||
3. Measure and report improvements
|
||||
@@ -267,18 +292,21 @@ test.beforeEach(async ({ page }) => {
|
||||
## Metrics to Track
|
||||
|
||||
Before optimization:
|
||||
|
||||
- Total time: ~2100s (35 minutes)
|
||||
- Avg test time: 9.1s
|
||||
- Slowest test: 20.1s (settings navigation)
|
||||
- Flaky tests: 1
|
||||
|
||||
After Phase 1+2 target:
|
||||
|
||||
- Total time: <1200s (20 minutes) ✅
|
||||
- Avg test time: <5.5s ✅
|
||||
- Slowest test: <12s ✅
|
||||
- Flaky tests: 0 ✅
|
||||
|
||||
After Phase 3 target:
|
||||
|
||||
- Total time: <1050s (17 minutes) 🎯
|
||||
- Avg test time: <4.8s 🎯
|
||||
- Slowest test: <10s 🎯
|
||||
|
||||
@@ -69,8 +69,7 @@ export function useCreateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateUserDto) =>
|
||||
AdminService.createUser({ requestBody: data }),
|
||||
mutationFn: (data: CreateUserDto) => AdminService.createUser({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
toast.success('User created successfully');
|
||||
@@ -106,8 +105,7 @@ export function useDeleteUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) =>
|
||||
AdminService.deleteUser({ userId }),
|
||||
mutationFn: (userId: string) => AdminService.deleteUser({ userId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
toast.success('User deleted successfully');
|
||||
@@ -145,7 +143,7 @@ export function useBulkUserAction() {
|
||||
return useMutation({
|
||||
mutationFn: ({ action, userIds }: { action: string; userIds: string[] }) =>
|
||||
AdminService.bulkUserAction({
|
||||
requestBody: { action, user_ids: userIds }
|
||||
requestBody: { action, user_ids: userIds },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
@@ -592,6 +590,7 @@ export default function NewUserPage() {
|
||||
### Testing the Feature
|
||||
|
||||
**Test the user management flow:**
|
||||
|
||||
1. Navigate to `/admin/users`
|
||||
2. Search for users
|
||||
3. Click "Create User" and fill the form
|
||||
@@ -630,8 +629,7 @@ export function useRevokeSession() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) =>
|
||||
SessionsService.revokeSession({ sessionId }),
|
||||
mutationFn: (sessionId: string) => SessionsService.revokeSession({ sessionId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['sessions'] });
|
||||
toast.success('Session revoked successfully');
|
||||
@@ -1041,11 +1039,13 @@ export default function AdminDashboardPage() {
|
||||
## Conclusion
|
||||
|
||||
These examples demonstrate:
|
||||
|
||||
1. **Complete CRUD operations** (User Management)
|
||||
2. **Real-time data with polling** (Session Management)
|
||||
3. **Data visualization** (Admin Dashboard Charts)
|
||||
|
||||
Each example follows the established patterns:
|
||||
|
||||
- API hooks for data fetching
|
||||
- Reusable components
|
||||
- Proper error handling
|
||||
@@ -1053,6 +1053,7 @@ Each example follows the established patterns:
|
||||
- Type safety with TypeScript
|
||||
|
||||
For more patterns and best practices, refer to:
|
||||
|
||||
- **ARCHITECTURE.md**: System design
|
||||
- **CODING_STANDARDS.md**: Code style
|
||||
- **COMPONENT_GUIDE.md**: Component usage
|
||||
|
||||
@@ -66,7 +66,7 @@ import {
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter
|
||||
CardFooter,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
<Card>
|
||||
@@ -80,7 +80,7 @@ import {
|
||||
<CardFooter>
|
||||
<Button>Save</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Card>;
|
||||
```
|
||||
|
||||
**[See card examples](/dev/components#card)**
|
||||
@@ -95,18 +95,9 @@ import { Input } from '@/components/ui/input';
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Input id="email" type="email" placeholder="you@example.com" {...register('email')} />
|
||||
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
|
||||
</div>;
|
||||
```
|
||||
|
||||
**[See form patterns](./06-forms.md)** | **[Form examples](/dev/forms)**
|
||||
@@ -123,7 +114,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTrigger
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
<Dialog>
|
||||
@@ -133,16 +124,14 @@ import {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Action</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to proceed?
|
||||
</DialogDescription>
|
||||
<DialogDescription>Are you sure you want to proceed?</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button>Confirm</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>;
|
||||
```
|
||||
|
||||
**[See dialog examples](/dev/components#dialog)**
|
||||
@@ -197,7 +186,7 @@ import { AlertCircle } from 'lucide-react';
|
||||
```tsx
|
||||
// Responsive card grid
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{items.map(item => (
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{item.title}</CardTitle>
|
||||
@@ -218,9 +207,7 @@ import { AlertCircle } from 'lucide-react';
|
||||
<CardTitle>Login</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4">
|
||||
{/* Form fields */}
|
||||
</form>
|
||||
<form className="space-y-4">{/* Form fields */}</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -247,6 +234,7 @@ import { AlertCircle } from 'lucide-react';
|
||||
```
|
||||
|
||||
**Available tokens:**
|
||||
|
||||
- `primary` - Main brand color, CTAs
|
||||
- `destructive` - Errors, delete actions
|
||||
- `muted` - Disabled states, subtle backgrounds
|
||||
@@ -276,6 +264,7 @@ import { AlertCircle } from 'lucide-react';
|
||||
```
|
||||
|
||||
**Common spacing values:**
|
||||
|
||||
- `2` (8px) - Tight spacing
|
||||
- `4` (16px) - Standard spacing
|
||||
- `6` (24px) - Section spacing
|
||||
@@ -326,6 +315,7 @@ import { AlertCircle } from 'lucide-react';
|
||||
```
|
||||
|
||||
**Breakpoints:**
|
||||
|
||||
- `sm:` 640px+
|
||||
- `md:` 768px+
|
||||
- `lg:` 1024px+
|
||||
@@ -370,11 +360,9 @@ import { AlertCircle } from 'lucide-react';
|
||||
```tsx
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-12 w-full" />
|
||||
) : (
|
||||
<div>{content}</div>
|
||||
)}
|
||||
{
|
||||
isLoading ? <Skeleton className="h-12 w-full" /> : <div>{content}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Dropdown Menu
|
||||
@@ -384,7 +372,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
<DropdownMenu>
|
||||
@@ -395,7 +383,7 @@ import {
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenu>;
|
||||
```
|
||||
|
||||
### Badge/Tag
|
||||
@@ -415,17 +403,20 @@ import { Badge } from '@/components/ui/badge';
|
||||
You now know enough to build most interfaces! For deeper knowledge:
|
||||
|
||||
### Learn More
|
||||
|
||||
- **Components**: [Complete component guide](./02-components.md)
|
||||
- **Layouts**: [Layout patterns](./03-layouts.md)
|
||||
- **Forms**: [Form patterns & validation](./06-forms.md)
|
||||
- **Custom Components**: [Component creation guide](./05-component-creation.md)
|
||||
|
||||
### Interactive Examples
|
||||
|
||||
- **[Component Showcase](/dev/components)** - All components with code
|
||||
- **[Layout Examples](/dev/layouts)** - Before/after comparisons
|
||||
- **[Form Examples](/dev/forms)** - Complete form implementations
|
||||
|
||||
### Reference
|
||||
|
||||
- **[Quick Reference Tables](./99-reference.md)** - Bookmark this for lookups
|
||||
- **[Foundations](./01-foundations.md)** - Complete color/spacing/typography guide
|
||||
|
||||
@@ -449,6 +440,7 @@ Remember these and you'll be 95% compliant:
|
||||
You're ready to build. When you hit edge cases or need advanced patterns, refer back to the [full documentation](./README.md).
|
||||
|
||||
**Bookmark these:**
|
||||
|
||||
- [Quick Reference](./99-reference.md) - For quick lookups
|
||||
- [AI Guidelines](./08-ai-guidelines.md) - If using AI assistants
|
||||
- [Component Showcase](/dev/components) - For copy-paste examples
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
### Why OKLCH?
|
||||
|
||||
We use **OKLCH** (Oklab LCH) color space for:
|
||||
|
||||
- ✅ **Perceptual uniformity** - Colors look consistent across light/dark modes
|
||||
- ✅ **Better accessibility** - Predictable contrast ratios
|
||||
- ✅ **Vibrant colors** - More saturated without sacrificing legibility
|
||||
@@ -55,6 +56,7 @@ We use **OKLCH** (Oklab LCH) color space for:
|
||||
### Semantic Color Tokens
|
||||
|
||||
All colors follow the **background/foreground** convention:
|
||||
|
||||
- `background` - The background color
|
||||
- `foreground` - The text color that goes on that background
|
||||
|
||||
@@ -68,11 +70,12 @@ All colors follow the **background/foreground** convention:
|
||||
|
||||
```css
|
||||
/* Light & Dark Mode */
|
||||
--primary: oklch(0.6231 0.1880 259.8145) /* Blue */
|
||||
--primary-foreground: oklch(1 0 0) /* White text */
|
||||
--primary: oklch(0.6231 0.188 259.8145) /* Blue */ --primary-foreground: oklch(1 0 0)
|
||||
/* White text */;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Primary button (most common)
|
||||
<Button>Save Changes</Button>
|
||||
@@ -87,12 +90,14 @@ All colors follow the **background/foreground** convention:
|
||||
```
|
||||
|
||||
**When to use**:
|
||||
|
||||
- ✅ Call-to-action buttons
|
||||
- ✅ Primary links
|
||||
- ✅ Active states in navigation
|
||||
- ✅ Important badges/tags
|
||||
|
||||
**When NOT to use**:
|
||||
|
||||
- ❌ Large background areas (too intense)
|
||||
- ❌ Body text (use `text-foreground`)
|
||||
- ❌ Disabled states (use `muted`)
|
||||
@@ -105,15 +110,14 @@ All colors follow the **background/foreground** convention:
|
||||
|
||||
```css
|
||||
/* Light Mode */
|
||||
--secondary: oklch(0.9670 0.0029 264.5419) /* Light gray-blue */
|
||||
--secondary-foreground: oklch(0.1529 0 0) /* Dark text */
|
||||
|
||||
/* Dark Mode */
|
||||
--secondary: oklch(0.2686 0 0) /* Dark gray */
|
||||
--secondary-foreground: oklch(0.9823 0 0) /* Light text */
|
||||
--secondary: oklch(0.967 0.0029 264.5419) /* Light gray-blue */
|
||||
--secondary-foreground: oklch(0.1529 0 0) /* Dark text */ /* Dark Mode */
|
||||
--secondary: oklch(0.2686 0 0) /* Dark gray */ --secondary-foreground: oklch(0.9823 0 0)
|
||||
/* Light text */;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Secondary button
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
@@ -135,15 +139,12 @@ All colors follow the **background/foreground** convention:
|
||||
|
||||
```css
|
||||
/* Light Mode */
|
||||
--muted: oklch(0.9846 0.0017 247.8389)
|
||||
--muted-foreground: oklch(0.4667 0.0043 264.4327)
|
||||
|
||||
/* Dark Mode */
|
||||
--muted: oklch(0.2393 0 0)
|
||||
--muted-foreground: oklch(0.6588 0.0043 264.4327)
|
||||
--muted: oklch(0.9846 0.0017 247.8389) --muted-foreground: oklch(0.4667 0.0043 264.4327)
|
||||
/* Dark Mode */ --muted: oklch(0.2393 0 0) --muted-foreground: oklch(0.6588 0.0043 264.4327);
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Disabled button
|
||||
<Button disabled>Submit</Button>
|
||||
@@ -165,6 +166,7 @@ All colors follow the **background/foreground** convention:
|
||||
```
|
||||
|
||||
**Common use cases**:
|
||||
|
||||
- Disabled button backgrounds
|
||||
- Placeholder/skeleton loaders
|
||||
- TabsList backgrounds
|
||||
@@ -179,15 +181,12 @@ All colors follow the **background/foreground** convention:
|
||||
|
||||
```css
|
||||
/* Light Mode */
|
||||
--accent: oklch(0.9514 0.0250 236.8242)
|
||||
--accent-foreground: oklch(0.1529 0 0)
|
||||
|
||||
/* Dark Mode */
|
||||
--accent: oklch(0.3791 0.1378 265.5222)
|
||||
--accent-foreground: oklch(0.9823 0 0)
|
||||
--accent: oklch(0.9514 0.025 236.8242) --accent-foreground: oklch(0.1529 0 0) /* Dark Mode */
|
||||
--accent: oklch(0.3791 0.1378 265.5222) --accent-foreground: oklch(0.9823 0 0);
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Dropdown menu item hover
|
||||
<DropdownMenu>
|
||||
@@ -205,6 +204,7 @@ All colors follow the **background/foreground** convention:
|
||||
```
|
||||
|
||||
**Common use cases**:
|
||||
|
||||
- Dropdown menu item hover states
|
||||
- Command palette hover states
|
||||
- Highlighted sections
|
||||
@@ -218,11 +218,12 @@ All colors follow the **background/foreground** convention:
|
||||
|
||||
```css
|
||||
/* Light & Dark Mode */
|
||||
--destructive: oklch(0.6368 0.2078 25.3313) /* Red */
|
||||
--destructive-foreground: oklch(1 0 0) /* White text */
|
||||
--destructive: oklch(0.6368 0.2078 25.3313) /* Red */ --destructive-foreground: oklch(1 0 0)
|
||||
/* White text */;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Delete button
|
||||
<Button variant="destructive">Delete Account</Button>
|
||||
@@ -246,6 +247,7 @@ All colors follow the **background/foreground** convention:
|
||||
```
|
||||
|
||||
**When to use**:
|
||||
|
||||
- ✅ Delete/remove actions
|
||||
- ✅ Error messages
|
||||
- ✅ Validation errors
|
||||
@@ -259,19 +261,15 @@ All colors follow the **background/foreground** convention:
|
||||
|
||||
```css
|
||||
/* Light Mode */
|
||||
--card: oklch(1.0000 0 0) /* White */
|
||||
--card-foreground: oklch(0.1529 0 0) /* Dark text */
|
||||
--popover: oklch(1.0000 0 0) /* White */
|
||||
--popover-foreground: oklch(0.1529 0 0) /* Dark text */
|
||||
|
||||
/* Dark Mode */
|
||||
--card: oklch(0.2686 0 0) /* Dark gray */
|
||||
--card-foreground: oklch(0.9823 0 0) /* Light text */
|
||||
--popover: oklch(0.2686 0 0) /* Dark gray */
|
||||
--popover-foreground: oklch(0.9823 0 0) /* Light text */
|
||||
--card: oklch(1 0 0) /* White */ --card-foreground: oklch(0.1529 0 0) /* Dark text */
|
||||
--popover: oklch(1 0 0) /* White */ --popover-foreground: oklch(0.1529 0 0) /* Dark text */
|
||||
/* Dark Mode */ --card: oklch(0.2686 0 0) /* Dark gray */ --card-foreground: oklch(0.9823 0 0)
|
||||
/* Light text */ --popover: oklch(0.2686 0 0) /* Dark gray */
|
||||
--popover-foreground: oklch(0.9823 0 0) /* Light text */;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Card (uses card colors by default)
|
||||
<Card>
|
||||
@@ -296,15 +294,12 @@ All colors follow the **background/foreground** convention:
|
||||
|
||||
```css
|
||||
/* Light Mode */
|
||||
--border: oklch(0.9276 0.0058 264.5313)
|
||||
--input: oklch(0.9276 0.0058 264.5313)
|
||||
|
||||
/* Dark Mode */
|
||||
--border: oklch(0.3715 0 0)
|
||||
--input: oklch(0.3715 0 0)
|
||||
--border: oklch(0.9276 0.0058 264.5313) --input: oklch(0.9276 0.0058 264.5313) /* Dark Mode */
|
||||
--border: oklch(0.3715 0 0) --input: oklch(0.3715 0 0);
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Input border
|
||||
<Input type="email" placeholder="you@example.com" />
|
||||
@@ -329,10 +324,11 @@ All colors follow the **background/foreground** convention:
|
||||
|
||||
```css
|
||||
/* Light & Dark Mode */
|
||||
--ring: oklch(0.6231 0.1880 259.8145) /* Primary blue */
|
||||
--ring: oklch(0.6231 0.188 259.8145) /* Primary blue */;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Button with focus ring (automatic)
|
||||
<Button>Click me</Button>
|
||||
@@ -355,14 +351,14 @@ All colors follow the **background/foreground** convention:
|
||||
**Purpose**: Data visualization with harmonious color palette
|
||||
|
||||
```css
|
||||
--chart-1: oklch(0.6231 0.1880 259.8145) /* Blue */
|
||||
--chart-2: oklch(0.5461 0.2152 262.8809) /* Purple-blue */
|
||||
--chart-3: oklch(0.4882 0.2172 264.3763) /* Deep purple */
|
||||
--chart-4: oklch(0.4244 0.1809 265.6377) /* Violet */
|
||||
--chart-5: oklch(0.3791 0.1378 265.5222) /* Deep violet */
|
||||
--chart-1: oklch(0.6231 0.188 259.8145) /* Blue */ --chart-2: oklch(0.5461 0.2152 262.8809)
|
||||
/* Purple-blue */ --chart-3: oklch(0.4882 0.2172 264.3763) /* Deep purple */
|
||||
--chart-4: oklch(0.4244 0.1809 265.6377) /* Violet */ --chart-5: oklch(0.3791 0.1378 265.5222)
|
||||
/* Deep violet */;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// In chart components
|
||||
const COLORS = [
|
||||
@@ -436,12 +432,13 @@ What's the purpose?
|
||||
### Font Families
|
||||
|
||||
```css
|
||||
--font-sans: Geist Sans, system-ui, -apple-system, sans-serif
|
||||
--font-mono: Geist Mono, ui-monospace, monospace
|
||||
--font-serif: ui-serif, Georgia, serif
|
||||
--font-sans:
|
||||
Geist Sans, system-ui, -apple-system, sans-serif --font-mono: Geist Mono, ui-monospace,
|
||||
monospace --font-serif: ui-serif, Georgia, serif;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Sans serif (default)
|
||||
<div className="font-sans">Body text</div>
|
||||
@@ -457,21 +454,21 @@ What's the purpose?
|
||||
|
||||
### Type Scale
|
||||
|
||||
| Size | Class | rem | px | Use Case |
|
||||
|------|-------|-----|----|----|
|
||||
| 9xl | `text-9xl` | 8rem | 128px | Hero text (rare) |
|
||||
| 8xl | `text-8xl` | 6rem | 96px | Hero text (rare) |
|
||||
| 7xl | `text-7xl` | 4.5rem | 72px | Hero text (rare) |
|
||||
| 6xl | `text-6xl` | 3.75rem | 60px | Hero text (rare) |
|
||||
| 5xl | `text-5xl` | 3rem | 48px | Landing page H1 |
|
||||
| 4xl | `text-4xl` | 2.25rem | 36px | Page H1 |
|
||||
| 3xl | `text-3xl` | 1.875rem | 30px | **Page titles** |
|
||||
| 2xl | `text-2xl` | 1.5rem | 24px | **Section headings** |
|
||||
| xl | `text-xl` | 1.25rem | 20px | **Card titles** |
|
||||
| lg | `text-lg` | 1.125rem | 18px | **Subheadings** |
|
||||
| base | `text-base` | 1rem | 16px | **Body text (default)** |
|
||||
| sm | `text-sm` | 0.875rem | 14px | **Secondary text, captions** |
|
||||
| xs | `text-xs` | 0.75rem | 12px | **Labels, helper text** |
|
||||
| Size | Class | rem | px | Use Case |
|
||||
| ---- | ----------- | -------- | ----- | ---------------------------- |
|
||||
| 9xl | `text-9xl` | 8rem | 128px | Hero text (rare) |
|
||||
| 8xl | `text-8xl` | 6rem | 96px | Hero text (rare) |
|
||||
| 7xl | `text-7xl` | 4.5rem | 72px | Hero text (rare) |
|
||||
| 6xl | `text-6xl` | 3.75rem | 60px | Hero text (rare) |
|
||||
| 5xl | `text-5xl` | 3rem | 48px | Landing page H1 |
|
||||
| 4xl | `text-4xl` | 2.25rem | 36px | Page H1 |
|
||||
| 3xl | `text-3xl` | 1.875rem | 30px | **Page titles** |
|
||||
| 2xl | `text-2xl` | 1.5rem | 24px | **Section headings** |
|
||||
| xl | `text-xl` | 1.25rem | 20px | **Card titles** |
|
||||
| lg | `text-lg` | 1.125rem | 18px | **Subheadings** |
|
||||
| base | `text-base` | 1rem | 16px | **Body text (default)** |
|
||||
| sm | `text-sm` | 0.875rem | 14px | **Secondary text, captions** |
|
||||
| xs | `text-xs` | 0.75rem | 12px | **Labels, helper text** |
|
||||
|
||||
**Bold = most commonly used**
|
||||
|
||||
@@ -479,13 +476,13 @@ What's the purpose?
|
||||
|
||||
### Font Weights
|
||||
|
||||
| Weight | Class | Numeric | Use Case |
|
||||
|--------|-------|---------|----------|
|
||||
| Bold | `font-bold` | 700 | **Headings, emphasis** |
|
||||
| Semibold | `font-semibold` | 600 | **Subheadings, buttons** |
|
||||
| Medium | `font-medium` | 500 | **Labels, menu items** |
|
||||
| Normal | `font-normal` | 400 | **Body text (default)** |
|
||||
| Light | `font-light` | 300 | De-emphasized text |
|
||||
| Weight | Class | Numeric | Use Case |
|
||||
| -------- | --------------- | ------- | ------------------------ |
|
||||
| Bold | `font-bold` | 700 | **Headings, emphasis** |
|
||||
| Semibold | `font-semibold` | 600 | **Subheadings, buttons** |
|
||||
| Medium | `font-medium` | 500 | **Labels, menu items** |
|
||||
| Normal | `font-normal` | 400 | **Body text (default)** |
|
||||
| Light | `font-light` | 300 | De-emphasized text |
|
||||
|
||||
**Bold = most commonly used**
|
||||
|
||||
@@ -494,35 +491,37 @@ What's the purpose?
|
||||
### Typography Patterns
|
||||
|
||||
#### Page Title
|
||||
|
||||
```tsx
|
||||
<h1 className="text-3xl font-bold">Page Title</h1>
|
||||
```
|
||||
|
||||
#### Section Heading
|
||||
|
||||
```tsx
|
||||
<h2 className="text-2xl font-semibold mb-4">Section Heading</h2>
|
||||
```
|
||||
|
||||
#### Card Title
|
||||
|
||||
```tsx
|
||||
<CardTitle className="text-xl font-semibold">Card Title</CardTitle>
|
||||
```
|
||||
|
||||
#### Body Text
|
||||
|
||||
```tsx
|
||||
<p className="text-base text-foreground">
|
||||
Regular paragraph text uses the default text-base size.
|
||||
</p>
|
||||
<p className="text-base text-foreground">Regular paragraph text uses the default text-base size.</p>
|
||||
```
|
||||
|
||||
#### Secondary Text
|
||||
|
||||
```tsx
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Helper text, timestamps, captions
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Helper text, timestamps, captions</p>
|
||||
```
|
||||
|
||||
#### Label
|
||||
|
||||
```tsx
|
||||
<Label htmlFor="email" className="text-sm font-medium">
|
||||
Email Address
|
||||
@@ -533,16 +532,17 @@ What's the purpose?
|
||||
|
||||
### Line Height
|
||||
|
||||
| Class | Value | Use Case |
|
||||
|-------|-------|----------|
|
||||
| `leading-none` | 1 | Headings (rare) |
|
||||
| `leading-tight` | 1.25 | **Headings** |
|
||||
| `leading-snug` | 1.375 | Dense text |
|
||||
| `leading-normal` | 1.5 | **Body text (default)** |
|
||||
| `leading-relaxed` | 1.625 | Comfortable reading |
|
||||
| `leading-loose` | 2 | Very relaxed (rare) |
|
||||
| Class | Value | Use Case |
|
||||
| ----------------- | ----- | ----------------------- |
|
||||
| `leading-none` | 1 | Headings (rare) |
|
||||
| `leading-tight` | 1.25 | **Headings** |
|
||||
| `leading-snug` | 1.375 | Dense text |
|
||||
| `leading-normal` | 1.5 | **Body text (default)** |
|
||||
| `leading-relaxed` | 1.625 | Comfortable reading |
|
||||
| `leading-loose` | 2 | Very relaxed (rare) |
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Heading
|
||||
<h1 className="text-3xl font-bold leading-tight">
|
||||
@@ -622,23 +622,23 @@ Tailwind uses a **0.25rem (4px) base unit**:
|
||||
|
||||
### Spacing Tokens
|
||||
|
||||
| Token | rem | Pixels | Use Case |
|
||||
|-------|-----|--------|----------|
|
||||
| `0` | 0 | 0px | No spacing |
|
||||
| `px` | - | 1px | Borders, dividers |
|
||||
| `0.5` | 0.125rem | 2px | Very tight |
|
||||
| `1` | 0.25rem | 4px | Icon gaps |
|
||||
| `2` | 0.5rem | 8px | **Tight spacing** (label → input) |
|
||||
| `3` | 0.75rem | 12px | Component padding |
|
||||
| `4` | 1rem | 16px | **Standard spacing** (form fields) |
|
||||
| `5` | 1.25rem | 20px | Medium spacing |
|
||||
| `6` | 1.5rem | 24px | **Section spacing** (cards) |
|
||||
| `8` | 2rem | 32px | **Large gaps** |
|
||||
| `10` | 2.5rem | 40px | Very large gaps |
|
||||
| `12` | 3rem | 48px | **Section dividers** |
|
||||
| `16` | 4rem | 64px | **Page sections** |
|
||||
| `20` | 5rem | 80px | Extra large |
|
||||
| `24` | 6rem | 96px | Huge spacing |
|
||||
| Token | rem | Pixels | Use Case |
|
||||
| ----- | -------- | ------ | ---------------------------------- |
|
||||
| `0` | 0 | 0px | No spacing |
|
||||
| `px` | - | 1px | Borders, dividers |
|
||||
| `0.5` | 0.125rem | 2px | Very tight |
|
||||
| `1` | 0.25rem | 4px | Icon gaps |
|
||||
| `2` | 0.5rem | 8px | **Tight spacing** (label → input) |
|
||||
| `3` | 0.75rem | 12px | Component padding |
|
||||
| `4` | 1rem | 16px | **Standard spacing** (form fields) |
|
||||
| `5` | 1.25rem | 20px | Medium spacing |
|
||||
| `6` | 1.5rem | 24px | **Section spacing** (cards) |
|
||||
| `8` | 2rem | 32px | **Large gaps** |
|
||||
| `10` | 2.5rem | 40px | Very large gaps |
|
||||
| `12` | 3rem | 48px | **Section dividers** |
|
||||
| `16` | 4rem | 64px | **Page sections** |
|
||||
| `20` | 5rem | 80px | Extra large |
|
||||
| `24` | 6rem | 96px | Huge spacing |
|
||||
|
||||
**Bold = most commonly used**
|
||||
|
||||
@@ -660,18 +660,18 @@ Tailwind uses a **0.25rem (4px) base unit**:
|
||||
|
||||
### Max Width Scale
|
||||
|
||||
| Class | Pixels | Use Case |
|
||||
|-------|--------|----------|
|
||||
| `max-w-xs` | 320px | Tiny cards |
|
||||
| `max-w-sm` | 384px | Small cards |
|
||||
| `max-w-md` | 448px | **Forms** |
|
||||
| `max-w-lg` | 512px | **Modals** |
|
||||
| `max-w-xl` | 576px | Medium content |
|
||||
| `max-w-2xl` | 672px | **Article content** |
|
||||
| `max-w-3xl` | 768px | Documentation |
|
||||
| `max-w-4xl` | 896px | **Wide layouts** |
|
||||
| `max-w-5xl` | 1024px | Extra wide |
|
||||
| `max-w-6xl` | 1152px | Very wide |
|
||||
| Class | Pixels | Use Case |
|
||||
| ----------- | ------ | ------------------- |
|
||||
| `max-w-xs` | 320px | Tiny cards |
|
||||
| `max-w-sm` | 384px | Small cards |
|
||||
| `max-w-md` | 448px | **Forms** |
|
||||
| `max-w-lg` | 512px | **Modals** |
|
||||
| `max-w-xl` | 576px | Medium content |
|
||||
| `max-w-2xl` | 672px | **Article content** |
|
||||
| `max-w-3xl` | 768px | Documentation |
|
||||
| `max-w-4xl` | 896px | **Wide layouts** |
|
||||
| `max-w-5xl` | 1024px | Extra wide |
|
||||
| `max-w-6xl` | 1152px | Very wide |
|
||||
| `max-w-7xl` | 1280px | **Full page width** |
|
||||
|
||||
**Bold = most commonly used**
|
||||
@@ -729,27 +729,28 @@ Tailwind uses a **0.25rem (4px) base unit**:
|
||||
Professional shadow system for depth and elevation:
|
||||
|
||||
```css
|
||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05)
|
||||
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10)
|
||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10)
|
||||
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10)
|
||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10)
|
||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10)
|
||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25)
|
||||
--shadow-xs:
|
||||
0 1px 3px 0px hsl(0 0% 0% / 0.05) --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 1px 2px -1px hsl(0 0% 0% / 0.1) --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 1px 2px -1px hsl(0 0% 0% / 0.1) --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 2px 4px -1px hsl(0 0% 0% / 0.1) --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 4px 6px -1px hsl(0 0% 0% / 0.1) --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 8px 10px -1px hsl(0 0% 0% / 0.1) --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||
```
|
||||
|
||||
### Shadow Usage
|
||||
|
||||
| Elevation | Class | Use Case |
|
||||
|-----------|-------|----------|
|
||||
| Base | No shadow | Buttons, inline elements |
|
||||
| Low | `shadow-sm` | **Cards, panels** |
|
||||
| Medium | `shadow-md` | **Dropdowns, tooltips** |
|
||||
| High | `shadow-lg` | **Modals, popovers** |
|
||||
| Highest | `shadow-xl` | Notifications, floating elements |
|
||||
| Maximum | `shadow-2xl` | Dialogs (rare) |
|
||||
| Elevation | Class | Use Case |
|
||||
| --------- | ------------ | -------------------------------- |
|
||||
| Base | No shadow | Buttons, inline elements |
|
||||
| Low | `shadow-sm` | **Cards, panels** |
|
||||
| Medium | `shadow-md` | **Dropdowns, tooltips** |
|
||||
| High | `shadow-lg` | **Modals, popovers** |
|
||||
| Highest | `shadow-xl` | Notifications, floating elements |
|
||||
| Maximum | `shadow-2xl` | Dialogs (rare) |
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Card with subtle shadow
|
||||
<Card className="shadow-sm">Card content</Card>
|
||||
@@ -779,26 +780,24 @@ Professional shadow system for depth and elevation:
|
||||
Consistent rounded corners across the application:
|
||||
|
||||
```css
|
||||
--radius: 0.375rem; /* 6px - base */
|
||||
--radius: 0.375rem; /* 6px - base */
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px) /* 2px */
|
||||
--radius-md: calc(var(--radius) - 2px) /* 4px */
|
||||
--radius-lg: var(--radius) /* 6px */
|
||||
--radius-xl: calc(var(--radius) + 4px) /* 10px */
|
||||
--radius-sm: calc(var(--radius) - 4px) /* 2px */ --radius-md: calc(var(--radius) - 2px) /* 4px */
|
||||
--radius-lg: var(--radius) /* 6px */ --radius-xl: calc(var(--radius) + 4px) /* 10px */;
|
||||
```
|
||||
|
||||
### Border Radius Scale
|
||||
|
||||
| Token | Class | Pixels | Use Case |
|
||||
|-------|-------|--------|----------|
|
||||
| None | `rounded-none` | 0px | Square elements |
|
||||
| Small | `rounded-sm` | 2px | **Tags, small badges** |
|
||||
| Medium | `rounded-md` | 4px | **Inputs, small buttons** |
|
||||
| Large | `rounded-lg` | 6px | **Cards, buttons (default)** |
|
||||
| XL | `rounded-xl` | 10px | **Large cards, modals** |
|
||||
| 2XL | `rounded-2xl` | 16px | Hero sections |
|
||||
| 3XL | `rounded-3xl` | 24px | Very rounded |
|
||||
| Full | `rounded-full` | 9999px | **Pills, avatars, icon buttons** |
|
||||
| Token | Class | Pixels | Use Case |
|
||||
| ------ | -------------- | ------ | -------------------------------- |
|
||||
| None | `rounded-none` | 0px | Square elements |
|
||||
| Small | `rounded-sm` | 2px | **Tags, small badges** |
|
||||
| Medium | `rounded-md` | 4px | **Inputs, small buttons** |
|
||||
| Large | `rounded-lg` | 6px | **Cards, buttons (default)** |
|
||||
| XL | `rounded-xl` | 10px | **Large cards, modals** |
|
||||
| 2XL | `rounded-2xl` | 16px | Hero sections |
|
||||
| 3XL | `rounded-3xl` | 24px | Very rounded |
|
||||
| Full | `rounded-full` | 9999px | **Pills, avatars, icon buttons** |
|
||||
|
||||
**Bold = most commonly used**
|
||||
|
||||
@@ -854,6 +853,7 @@ Consistent rounded corners across the application:
|
||||
### Most Used Tokens
|
||||
|
||||
**Colors**:
|
||||
|
||||
- `bg-primary text-primary-foreground` - CTAs
|
||||
- `bg-destructive text-destructive-foreground` - Delete/errors
|
||||
- `bg-muted text-muted-foreground` - Disabled/subtle
|
||||
@@ -862,6 +862,7 @@ Consistent rounded corners across the application:
|
||||
- `border-border` - Borders
|
||||
|
||||
**Typography**:
|
||||
|
||||
- `text-3xl font-bold` - Page titles
|
||||
- `text-2xl font-semibold` - Section headings
|
||||
- `text-xl font-semibold` - Card titles
|
||||
@@ -869,6 +870,7 @@ Consistent rounded corners across the application:
|
||||
- `text-sm text-muted-foreground` - Secondary text
|
||||
|
||||
**Spacing**:
|
||||
|
||||
- `p-4` - Standard padding (16px)
|
||||
- `p-6` - Card padding (24px)
|
||||
- `gap-4` - Standard gap (16px)
|
||||
@@ -877,6 +879,7 @@ Consistent rounded corners across the application:
|
||||
- `space-y-6` - Section spacing (24px)
|
||||
|
||||
**Shadows & Radius**:
|
||||
|
||||
- `shadow-sm` - Cards
|
||||
- `shadow-md` - Dropdowns
|
||||
- `shadow-lg` - Modals
|
||||
@@ -896,12 +899,14 @@ Consistent rounded corners across the application:
|
||||
---
|
||||
|
||||
**Related Documentation:**
|
||||
|
||||
- [Quick Start](./00-quick-start.md) - Essential patterns
|
||||
- [Components](./02-components.md) - shadcn/ui library
|
||||
- [Spacing Philosophy](./04-spacing-philosophy.md) - Margin vs padding strategy
|
||||
- [Accessibility](./07-accessibility.md) - WCAG compliance
|
||||
|
||||
**External Resources:**
|
||||
|
||||
- [OKLCH Color Picker](https://oklch.com)
|
||||
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
|
||||
- [Tailwind CSS Documentation](https://tailwindcss.com/docs)
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
We use **[shadcn/ui](https://ui.shadcn.com)**, a collection of accessible, customizable components built on **Radix UI primitives**.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- ✅ **Accessible** - WCAG AA compliant, keyboard navigation, screen reader support
|
||||
- ✅ **Customizable** - Components are copied into your project (not npm dependencies)
|
||||
- ✅ **Composable** - Build complex UIs from simple primitives
|
||||
@@ -41,6 +42,7 @@ npx shadcn@latest add
|
||||
```
|
||||
|
||||
**Installed components** (in `/src/components/ui/`):
|
||||
|
||||
- alert, avatar, badge, button, card, checkbox, dialog
|
||||
- dropdown-menu, input, label, popover, select, separator
|
||||
- sheet, skeleton, table, tabs, textarea, toast
|
||||
@@ -82,16 +84,17 @@ import { Button } from '@/components/ui/button';
|
||||
|
||||
**When to use each variant:**
|
||||
|
||||
| Variant | Use Case | Example |
|
||||
|---------|----------|---------|
|
||||
| `default` | Primary actions, CTAs | Save, Submit, Create |
|
||||
| `secondary` | Secondary actions | Cancel, Back |
|
||||
| `outline` | Alternative actions | View Details, Edit |
|
||||
| `ghost` | Subtle actions in lists | Icon buttons in table rows |
|
||||
| `link` | In-text actions | Read more, Learn more |
|
||||
| `destructive` | Delete, remove actions | Delete Account, Remove |
|
||||
| Variant | Use Case | Example |
|
||||
| ------------- | ----------------------- | -------------------------- |
|
||||
| `default` | Primary actions, CTAs | Save, Submit, Create |
|
||||
| `secondary` | Secondary actions | Cancel, Back |
|
||||
| `outline` | Alternative actions | View Details, Edit |
|
||||
| `ghost` | Subtle actions in lists | Icon buttons in table rows |
|
||||
| `link` | In-text actions | Read more, Learn more |
|
||||
| `destructive` | Delete, remove actions | Delete Account, Remove |
|
||||
|
||||
**Accessibility**:
|
||||
|
||||
- Always add `aria-label` for icon-only buttons
|
||||
- Use `disabled` for unavailable actions (not hidden)
|
||||
- Loading state prevents double-submission
|
||||
@@ -162,6 +165,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
```
|
||||
|
||||
**Pattern: User menu**:
|
||||
|
||||
```tsx
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
@@ -323,6 +327,7 @@ import { Label } from '@/components/ui/label';
|
||||
```
|
||||
|
||||
**Input types:**
|
||||
|
||||
- `text` - Default text input
|
||||
- `email` - Email address
|
||||
- `password` - Password field
|
||||
@@ -530,6 +535,7 @@ import { AlertCircle, CheckCircle, Info } from 'lucide-react';
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
|
||||
- ✅ Form-level errors
|
||||
- ✅ Important warnings
|
||||
- ✅ Success confirmations (inline)
|
||||
@@ -557,14 +563,11 @@ toast.info('Processing your request...');
|
||||
toast.warning('This action cannot be undone');
|
||||
|
||||
// Loading (with promise)
|
||||
toast.promise(
|
||||
saveChanges(),
|
||||
{
|
||||
loading: 'Saving changes...',
|
||||
success: 'Changes saved!',
|
||||
error: 'Failed to save changes',
|
||||
}
|
||||
);
|
||||
toast.promise(saveChanges(), {
|
||||
loading: 'Saving changes...',
|
||||
success: 'Changes saved!',
|
||||
error: 'Failed to save changes',
|
||||
});
|
||||
|
||||
// Custom with action
|
||||
toast('Event has been created', {
|
||||
@@ -580,6 +583,7 @@ toast.dismiss();
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
|
||||
- ✅ Action confirmations (saved, deleted)
|
||||
- ✅ Background task updates
|
||||
- ✅ Temporary errors
|
||||
@@ -629,12 +633,11 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
```
|
||||
|
||||
**Pattern: Loading states**:
|
||||
|
||||
```tsx
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-48 w-full" />
|
||||
) : (
|
||||
<div>{content}</div>
|
||||
)}
|
||||
{
|
||||
isLoading ? <Skeleton className="h-48 w-full" /> : <div>{content}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -654,7 +657,7 @@ import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
DialogClose
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
// Basic dialog
|
||||
@@ -678,7 +681,7 @@ import {
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>;
|
||||
|
||||
// Controlled dialog
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -695,10 +698,11 @@ const [isOpen, setIsOpen] = useState(false);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>;
|
||||
```
|
||||
|
||||
**Accessibility:**
|
||||
|
||||
- Escape key closes dialog
|
||||
- Focus trapped inside dialog
|
||||
- Returns focus to trigger on close
|
||||
@@ -916,7 +920,7 @@ import {
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption
|
||||
TableCaption,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
<Table>
|
||||
@@ -945,7 +949,7 @@ import {
|
||||
<TableCell className="text-right">$2,500.00</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</Table>;
|
||||
```
|
||||
|
||||
**For advanced tables** (sorting, filtering, pagination), use **TanStack Table** with react-hook-form.
|
||||
@@ -1014,12 +1018,14 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map(user => (
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.name}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm">Edit</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
Edit
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -1041,9 +1047,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New User</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new user to the system
|
||||
</DialogDescription>
|
||||
<DialogDescription>Add a new user to the system</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -1113,7 +1117,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
```tsx
|
||||
<Table>
|
||||
<TableBody>
|
||||
{users.map(user => (
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.name}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
@@ -1134,10 +1138,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(user)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<DropdownMenuItem onClick={() => handleDelete(user)} className="text-destructive">
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -1187,6 +1188,7 @@ Need switchable panels? → Tabs
|
||||
### Component Variants Quick Reference
|
||||
|
||||
**Button**:
|
||||
|
||||
- `default` - Primary action
|
||||
- `secondary` - Secondary action
|
||||
- `outline` - Alternative action
|
||||
@@ -1195,12 +1197,14 @@ Need switchable panels? → Tabs
|
||||
- `destructive` - Delete/remove
|
||||
|
||||
**Badge**:
|
||||
|
||||
- `default` - Blue (new, active)
|
||||
- `secondary` - Gray (draft, inactive)
|
||||
- `outline` - Bordered (pending)
|
||||
- `destructive` - Red (critical, error)
|
||||
|
||||
**Alert**:
|
||||
|
||||
- `default` - Info
|
||||
- `destructive` - Error
|
||||
|
||||
@@ -1216,12 +1220,14 @@ Need switchable panels? → Tabs
|
||||
---
|
||||
|
||||
**Related Documentation:**
|
||||
|
||||
- [Quick Start](./00-quick-start.md) - Essential patterns
|
||||
- [Foundations](./01-foundations.md) - Colors, typography, spacing
|
||||
- [Layouts](./03-layouts.md) - Layout patterns
|
||||
- [Forms](./06-forms.md) - Form validation and patterns
|
||||
|
||||
**External Resources:**
|
||||
|
||||
- [shadcn/ui Documentation](https://ui.shadcn.com)
|
||||
- [Radix UI Primitives](https://www.radix-ui.com)
|
||||
|
||||
|
||||
@@ -36,16 +36,16 @@ Use this flowchart to choose between Grid and Flex:
|
||||
|
||||
### Quick Rules
|
||||
|
||||
| Scenario | Solution |
|
||||
|----------|----------|
|
||||
| **Equal-width columns** | Grid (`grid grid-cols-3`) |
|
||||
| **Flexible item sizes** | Flex (`flex gap-4`) |
|
||||
| **2D layout (rows + cols)** | Grid (`grid grid-cols-2 grid-rows-3`) |
|
||||
| **1D layout (row OR col)** | Flex (`flex` or `flex flex-col`) |
|
||||
| **Card grid** | Grid (`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3`) |
|
||||
| **Navbar items** | Flex (`flex items-center gap-4`) |
|
||||
| **Sidebar + Content** | Flex (`flex gap-6`) |
|
||||
| **Form fields** | Flex column (`flex flex-col gap-4` or `space-y-4`) |
|
||||
| Scenario | Solution |
|
||||
| --------------------------- | ------------------------------------------------------- |
|
||||
| **Equal-width columns** | Grid (`grid grid-cols-3`) |
|
||||
| **Flexible item sizes** | Flex (`flex gap-4`) |
|
||||
| **2D layout (rows + cols)** | Grid (`grid grid-cols-2 grid-rows-3`) |
|
||||
| **1D layout (row OR col)** | Flex (`flex` or `flex flex-col`) |
|
||||
| **Card grid** | Grid (`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3`) |
|
||||
| **Navbar items** | Flex (`flex items-center gap-4`) |
|
||||
| **Sidebar + Content** | Flex (`flex gap-6`) |
|
||||
| **Form fields** | Flex column (`flex flex-col gap-4` or `space-y-4`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -68,15 +68,14 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
<CardHeader>
|
||||
<CardTitle>Section Title</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
Page content goes here
|
||||
</CardContent>
|
||||
<CardContent>Page content goes here</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- `container` - Responsive container with max-width
|
||||
- `mx-auto` - Center horizontally
|
||||
- `px-4` - Horizontal padding (mobile-friendly)
|
||||
@@ -85,6 +84,7 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
- `space-y-6` - Vertical spacing between children
|
||||
|
||||
**When to use:**
|
||||
|
||||
- Blog posts
|
||||
- Documentation pages
|
||||
- Settings pages
|
||||
@@ -103,7 +103,7 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{items.map(item => (
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{item.title}</CardTitle>
|
||||
@@ -119,11 +119,13 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
```
|
||||
|
||||
**Responsive behavior:**
|
||||
|
||||
- **Mobile** (`< 768px`): 1 column
|
||||
- **Tablet** (`≥ 768px`): 2 columns
|
||||
- **Desktop** (`≥ 1024px`): 3 columns
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- `grid` - Use CSS Grid
|
||||
- `grid-cols-1` - Default: 1 column (mobile-first)
|
||||
- `md:grid-cols-2` - 2 columns on tablet
|
||||
@@ -131,6 +133,7 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
- `gap-6` - Consistent spacing between items
|
||||
|
||||
**When to use:**
|
||||
|
||||
- Dashboards
|
||||
- Product grids
|
||||
- Image galleries
|
||||
@@ -171,17 +174,20 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- `max-w-md` - Constrain form width (448px max)
|
||||
- `mx-auto` - Center the form
|
||||
- `space-y-4` - Vertical spacing between fields
|
||||
- `w-full` - Full-width button
|
||||
|
||||
**Form width guidelines:**
|
||||
|
||||
- **Short forms** (login, signup): `max-w-md` (448px)
|
||||
- **Medium forms** (profile, settings): `max-w-lg` (512px)
|
||||
- **Long forms** (checkout): `max-w-2xl` (672px)
|
||||
|
||||
**When to use:**
|
||||
|
||||
- Login/signup forms
|
||||
- Contact forms
|
||||
- Settings forms
|
||||
@@ -220,6 +226,7 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- `flex` - Horizontal layout
|
||||
- `w-64` - Fixed sidebar width (256px)
|
||||
- `flex-1` - Main content takes remaining space
|
||||
@@ -249,6 +256,7 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
|
||||
- Admin dashboards
|
||||
- Settings pages
|
||||
- Documentation sites
|
||||
@@ -277,17 +285,20 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- `max-w-2xl` - Optimal reading width (672px)
|
||||
- `mx-auto` - Center content
|
||||
- `prose` - Typography styles (if using @tailwindcss/typography)
|
||||
|
||||
**Width recommendations:**
|
||||
|
||||
- **Articles/Blogs**: `max-w-2xl` (672px)
|
||||
- **Documentation**: `max-w-3xl` (768px)
|
||||
- **Landing pages**: `max-w-4xl` (896px) or wider
|
||||
- **Forms**: `max-w-md` (448px)
|
||||
|
||||
**When to use:**
|
||||
|
||||
- Blog posts
|
||||
- Articles
|
||||
- Documentation
|
||||
@@ -327,13 +338,13 @@ Always start with mobile layout, then enhance for larger screens:
|
||||
|
||||
### Breakpoints
|
||||
|
||||
| Breakpoint | Min Width | Typical Use |
|
||||
|------------|-----------|-------------|
|
||||
| `sm:` | 640px | Large phones, small tablets |
|
||||
| `md:` | 768px | Tablets |
|
||||
| `lg:` | 1024px | Laptops, desktops |
|
||||
| `xl:` | 1280px | Large desktops |
|
||||
| `2xl:` | 1536px | Extra large screens |
|
||||
| Breakpoint | Min Width | Typical Use |
|
||||
| ---------- | --------- | --------------------------- |
|
||||
| `sm:` | 640px | Large phones, small tablets |
|
||||
| `md:` | 768px | Tablets |
|
||||
| `lg:` | 1024px | Laptops, desktops |
|
||||
| `xl:` | 1280px | Large desktops |
|
||||
| `2xl:` | 1536px | Extra large screens |
|
||||
|
||||
### Responsive Grid Columns
|
||||
|
||||
@@ -457,12 +468,8 @@ grid-cols-1 lg:grid-cols-3
|
||||
```tsx
|
||||
// 2/3 - 1/3 split
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div className="col-span-2">
|
||||
Main content (2/3 width)
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
Sidebar (1/3 width)
|
||||
</div>
|
||||
<div className="col-span-2">Main content (2/3 width)</div>
|
||||
<div className="col-span-1">Sidebar (1/3 width)</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
@@ -482,12 +489,8 @@ grid-cols-1 lg:grid-cols-3
|
||||
|
||||
```tsx
|
||||
<div className="flex gap-6">
|
||||
<aside className="sticky top-6 h-fit w-64">
|
||||
{/* Stays in view while scrolling */}
|
||||
</aside>
|
||||
<main className="flex-1">
|
||||
{/* Scrollable content */}
|
||||
</main>
|
||||
<aside className="sticky top-6 h-fit w-64">{/* Stays in view while scrolling */}</aside>
|
||||
<main className="flex-1">{/* Scrollable content */}</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
@@ -579,6 +582,7 @@ w-full px-4
|
||||
---
|
||||
|
||||
**Related Documentation:**
|
||||
|
||||
- [Spacing Philosophy](./04-spacing-philosophy.md) - When to use margin vs padding vs gap
|
||||
- [Foundations](./01-foundations.md) - Spacing tokens and scale
|
||||
- [Quick Start](./00-quick-start.md) - Essential patterns
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
These 5 rules eliminate 90% of spacing inconsistencies:
|
||||
|
||||
### Rule 1: Parent Controls Children
|
||||
|
||||
**Children don't add their own margins. The parent controls spacing between siblings.**
|
||||
|
||||
```tsx
|
||||
@@ -40,6 +41,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
|
||||
- Eliminates "last child" edge cases
|
||||
- Makes components reusable (they work in any context)
|
||||
- Changes propagate from one place (parent)
|
||||
@@ -48,6 +50,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
|
||||
---
|
||||
|
||||
### Rule 2: Use Gap for Siblings
|
||||
|
||||
**For flex and grid layouts, use `gap-*` to space siblings.**
|
||||
|
||||
```tsx
|
||||
@@ -73,6 +76,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
|
||||
---
|
||||
|
||||
### Rule 3: Use Padding for Internal Spacing
|
||||
|
||||
**Padding is for spacing _inside_ a component, between the border and content.**
|
||||
|
||||
```tsx
|
||||
@@ -91,6 +95,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
|
||||
---
|
||||
|
||||
### Rule 4: Use space-y for Vertical Stacks
|
||||
|
||||
**For vertical stacks (not flex/grid), use `space-y-*` utility.**
|
||||
|
||||
```tsx
|
||||
@@ -110,6 +115,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
|
||||
```
|
||||
|
||||
**How space-y works:**
|
||||
|
||||
```css
|
||||
/* space-y-4 applies margin-top to all children except first */
|
||||
.space-y-4 > * + * {
|
||||
@@ -120,6 +126,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
|
||||
---
|
||||
|
||||
### Rule 5: Margins Only for Exceptions
|
||||
|
||||
**Use margin only when a specific child needs different spacing from its siblings.**
|
||||
|
||||
```tsx
|
||||
@@ -151,18 +158,19 @@ When children control their own margins:
|
||||
```tsx
|
||||
// ❌ ANTI-PATTERN
|
||||
function TodoItem({ className }: { className?: string }) {
|
||||
return <div className={cn("mb-4", className)}>Todo</div>;
|
||||
return <div className={cn('mb-4', className)}>Todo</div>;
|
||||
}
|
||||
|
||||
// Usage
|
||||
<div>
|
||||
<TodoItem /> {/* Has mb-4 */}
|
||||
<TodoItem /> {/* Has mb-4 */}
|
||||
<TodoItem /> {/* Has mb-4 - unwanted margin at bottom! */}
|
||||
</div>
|
||||
<TodoItem /> {/* Has mb-4 */}
|
||||
<TodoItem /> {/* Has mb-4 */}
|
||||
<TodoItem /> {/* Has mb-4 - unwanted margin at bottom! */}
|
||||
</div>;
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
|
||||
1. ❌ Last item has unwanted margin
|
||||
2. ❌ Can't change spacing without modifying component
|
||||
3. ❌ Margin collapsing creates unpredictable spacing
|
||||
@@ -199,6 +207,7 @@ function TodoItem({ className }: { className?: string }) {
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
1. ✅ No edge cases (last child, first child, only child)
|
||||
2. ✅ Spacing controlled in one place
|
||||
3. ✅ Component works in any layout context
|
||||
@@ -265,6 +274,7 @@ Use this flowchart to choose the right spacing method:
|
||||
```
|
||||
|
||||
**Spacing breakdown:**
|
||||
|
||||
- `space-y-4` on form: 16px between field groups
|
||||
- `space-y-2` on field group: 8px between label and input
|
||||
- No margins on children
|
||||
@@ -288,6 +298,7 @@ Use this flowchart to choose the right spacing method:
|
||||
```
|
||||
|
||||
**Why gap over space-x:**
|
||||
|
||||
- Works with `flex-wrap`
|
||||
- Works with `flex-col` (changes direction)
|
||||
- Consistent spacing in all directions
|
||||
@@ -306,6 +317,7 @@ Use this flowchart to choose the right spacing method:
|
||||
```
|
||||
|
||||
**Why gap:**
|
||||
|
||||
- Consistent spacing between rows and columns
|
||||
- Works with responsive grid changes
|
||||
- No edge cases (first row, last column, etc.)
|
||||
@@ -332,6 +344,7 @@ Use this flowchart to choose the right spacing method:
|
||||
```
|
||||
|
||||
**Spacing breakdown:**
|
||||
|
||||
- `p-6` on Card: 24px internal padding
|
||||
- `space-y-4` on CardContent: 16px between paragraphs
|
||||
- `pt-4` on CardFooter: Additional top padding for visual separation
|
||||
@@ -364,6 +377,7 @@ Use this flowchart to choose the right spacing method:
|
||||
```
|
||||
|
||||
**Spacing breakdown:**
|
||||
|
||||
- `px-4`: Horizontal padding (prevents edge touching)
|
||||
- `py-8`: Vertical padding (top and bottom spacing)
|
||||
- `space-y-6`: 24px between sections
|
||||
@@ -376,25 +390,28 @@ Use this flowchart to choose the right spacing method:
|
||||
### Example 1: Button Group
|
||||
|
||||
#### ❌ Before (Child-Controlled)
|
||||
|
||||
```tsx
|
||||
function ActionButton({ children, className }: Props) {
|
||||
return <Button className={cn("mr-4", className)}>{children}</Button>;
|
||||
return <Button className={cn('mr-4', className)}>{children}</Button>;
|
||||
}
|
||||
|
||||
// Usage
|
||||
<div className="flex">
|
||||
<ActionButton>Cancel</ActionButton>
|
||||
<ActionButton>Save</ActionButton>
|
||||
<ActionButton>Delete</ActionButton> {/* Unwanted mr-4 */}
|
||||
</div>
|
||||
<ActionButton>Delete</ActionButton> {/* Unwanted mr-4 */}
|
||||
</div>;
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
|
||||
- Last button has unwanted margin
|
||||
- Can't change spacing without modifying component
|
||||
- Hard to use in vertical layout
|
||||
|
||||
#### ✅ After (Parent-Controlled)
|
||||
|
||||
```tsx
|
||||
function ActionButton({ children, className }: Props) {
|
||||
return <Button className={className}>{children}</Button>;
|
||||
@@ -415,6 +432,7 @@ function ActionButton({ children, className }: Props) {
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- No edge cases
|
||||
- Reusable in any layout
|
||||
- Easy to change spacing
|
||||
@@ -424,6 +442,7 @@ function ActionButton({ children, className }: Props) {
|
||||
### Example 2: List Items
|
||||
|
||||
#### ❌ Before (Child-Controlled)
|
||||
|
||||
```tsx
|
||||
function ListItem({ title, description }: Props) {
|
||||
return (
|
||||
@@ -437,16 +456,18 @@ function ListItem({ title, description }: Props) {
|
||||
<div>
|
||||
<ListItem title="Item 1" description="..." />
|
||||
<ListItem title="Item 2" description="..." />
|
||||
<ListItem title="Item 3" description="..." /> {/* Unwanted mb-6 */}
|
||||
</div>
|
||||
<ListItem title="Item 3" description="..." /> {/* Unwanted mb-6 */}
|
||||
</div>;
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
|
||||
- Last item has unwanted bottom margin
|
||||
- Can't change list spacing without modifying component
|
||||
- Internal `mb-2` hard to override
|
||||
|
||||
#### ✅ After (Parent-Controlled)
|
||||
|
||||
```tsx
|
||||
function ListItem({ title, description }: Props) {
|
||||
return (
|
||||
@@ -473,6 +494,7 @@ function ListItem({ title, description }: Props) {
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- No unwanted margins
|
||||
- Internal spacing controlled by `space-y-2`
|
||||
- Reusable with different spacings
|
||||
@@ -482,6 +504,7 @@ function ListItem({ title, description }: Props) {
|
||||
### Example 3: Form Fields
|
||||
|
||||
#### ❌ Before (Mixed Strategy)
|
||||
|
||||
```tsx
|
||||
<form>
|
||||
<div className="mb-4">
|
||||
@@ -494,16 +517,20 @@ function ListItem({ title, description }: Props) {
|
||||
<Input id="email" className="mt-2" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="mt-6">Submit</Button>
|
||||
<Button type="submit" className="mt-6">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
|
||||
- Spacing scattered across children
|
||||
- Hard to change consistently
|
||||
- Have to remember `mt-6` for button
|
||||
|
||||
#### ✅ After (Parent-Controlled)
|
||||
|
||||
```tsx
|
||||
<form className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -516,11 +543,14 @@ function ListItem({ title, description }: Props) {
|
||||
<Input id="email" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="mt-2">Submit</Button>
|
||||
<Button type="submit" className="mt-2">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Spacing controlled in 2 places: form (`space-y-4`) and field groups (`space-y-2`)
|
||||
- Easy to change all field spacing at once
|
||||
- Consistent and predictable
|
||||
@@ -533,18 +563,20 @@ function ListItem({ title, description }: Props) {
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG
|
||||
{items.map((item, index) => (
|
||||
<Card key={item.id} className={index < items.length - 1 ? "mb-4" : ""}>
|
||||
{item.name}
|
||||
</Card>
|
||||
))}
|
||||
{
|
||||
items.map((item, index) => (
|
||||
<Card key={item.id} className={index < items.length - 1 ? 'mb-4' : ''}>
|
||||
{item.name}
|
||||
</Card>
|
||||
));
|
||||
}
|
||||
|
||||
// ✅ CORRECT
|
||||
<div className="space-y-4">
|
||||
{items.map(item => (
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>{item.name}</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -564,6 +596,7 @@ function ListItem({ title, description }: Props) {
|
||||
```
|
||||
|
||||
**Why negative margins are bad:**
|
||||
|
||||
- Indicates broken spacing strategy
|
||||
- Hard to maintain
|
||||
- Creates coupling between components
|
||||
@@ -618,26 +651,26 @@ function ListItem({ title, description }: Props) {
|
||||
|
||||
### Spacing Method Cheat Sheet
|
||||
|
||||
| Use Case | Method | Example |
|
||||
|----------|--------|---------|
|
||||
| **Flex siblings** | `gap-*` | `flex gap-4` |
|
||||
| **Grid siblings** | `gap-*` | `grid gap-6` |
|
||||
| **Vertical stack** | `space-y-*` | `space-y-4` |
|
||||
| **Horizontal stack** | `space-x-*` | `space-x-2` |
|
||||
| **Inside component** | `p-*` | `p-6` |
|
||||
| **One child exception** | `m-*` | `mt-8` |
|
||||
| Use Case | Method | Example |
|
||||
| ----------------------- | ----------- | ------------ |
|
||||
| **Flex siblings** | `gap-*` | `flex gap-4` |
|
||||
| **Grid siblings** | `gap-*` | `grid gap-6` |
|
||||
| **Vertical stack** | `space-y-*` | `space-y-4` |
|
||||
| **Horizontal stack** | `space-x-*` | `space-x-2` |
|
||||
| **Inside component** | `p-*` | `p-6` |
|
||||
| **One child exception** | `m-*` | `mt-8` |
|
||||
|
||||
### Common Spacing Values
|
||||
|
||||
| Class | Pixels | Usage |
|
||||
|-------|--------|-------|
|
||||
| `gap-2` or `space-y-2` | 8px | Tight (label + input) |
|
||||
| `gap-4` or `space-y-4` | 16px | Standard (form fields) |
|
||||
| `gap-6` or `space-y-6` | 24px | Sections (cards) |
|
||||
| `gap-8` or `space-y-8` | 32px | Large gaps |
|
||||
| `p-4` | 16px | Standard padding |
|
||||
| `p-6` | 24px | Card padding |
|
||||
| `px-4 py-8` | 16px / 32px | Page padding |
|
||||
| Class | Pixels | Usage |
|
||||
| ---------------------- | ----------- | ---------------------- |
|
||||
| `gap-2` or `space-y-2` | 8px | Tight (label + input) |
|
||||
| `gap-4` or `space-y-4` | 16px | Standard (form fields) |
|
||||
| `gap-6` or `space-y-6` | 24px | Sections (cards) |
|
||||
| `gap-8` or `space-y-8` | 32px | Large gaps |
|
||||
| `p-4` | 16px | Standard padding |
|
||||
| `p-6` | 24px | Card padding |
|
||||
| `px-4 py-8` | 16px / 32px | Page padding |
|
||||
|
||||
### Decision Flowchart (Simplified)
|
||||
|
||||
@@ -682,7 +715,7 @@ Need spacing?
|
||||
Before implementing spacing, verify:
|
||||
|
||||
- [ ] **Parent controls children?** Using gap or space-y/x?
|
||||
- [ ] **No child margins?** Components don't have mb-* or mr-*?
|
||||
- [ ] **No child margins?** Components don't have mb-_ or mr-_?
|
||||
- [ ] **Consistent method?** Not mixing gap + child margins?
|
||||
- [ ] **Reusable components?** Work in different contexts?
|
||||
- [ ] **No edge cases?** No last-child or first-child special handling?
|
||||
@@ -700,6 +733,7 @@ Before implementing spacing, verify:
|
||||
---
|
||||
|
||||
**Related Documentation:**
|
||||
|
||||
- [Layouts](./03-layouts.md) - When to use Grid vs Flex
|
||||
- [Foundations](./01-foundations.md) - Spacing scale tokens
|
||||
- [Component Creation](./05-component-creation.md) - Building reusable components
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
**80% of the time, you should COMPOSE existing shadcn/ui components.**
|
||||
|
||||
Only create custom components when:
|
||||
|
||||
1. ✅ You're reusing the same composition 3+ times
|
||||
2. ✅ The pattern has complex business logic
|
||||
3. ✅ You need variants beyond what shadcn/ui provides
|
||||
@@ -74,6 +75,7 @@ Do you need a UI element?
|
||||
```
|
||||
|
||||
**Why this is good:**
|
||||
|
||||
- Simple and direct
|
||||
- Easy to customize per use case
|
||||
- No abstraction overhead
|
||||
@@ -103,10 +105,11 @@ function ContentCard({ title, description, content, actionLabel, onAction }: Pro
|
||||
}
|
||||
|
||||
// Used once... why did we create this?
|
||||
<ContentCard title="..." description="..." content="..." />
|
||||
<ContentCard title="..." description="..." content="..." />;
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
|
||||
- ❌ Created before knowing if pattern is reused
|
||||
- ❌ Inflexible (what if we need 2 buttons?)
|
||||
- ❌ Unclear what it renders (abstraction hides structure)
|
||||
@@ -148,6 +151,7 @@ function DashboardMetricCard({
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
|
||||
- ✅ Pattern validated (used 3+ times)
|
||||
- ✅ Specific purpose (dashboard metrics)
|
||||
- ✅ Consistent structure across uses
|
||||
@@ -171,22 +175,23 @@ interface MyComponentProps {
|
||||
|
||||
export function MyComponent({ className, children }: MyComponentProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"base-classes-here", // Base styles
|
||||
className // Allow overrides
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'base-classes-here', // Base styles
|
||||
className // Allow overrides
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<MyComponent className="custom-overrides">
|
||||
Content
|
||||
</MyComponent>
|
||||
<MyComponent className="custom-overrides">Content</MyComponent>;
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Always accept `className` prop
|
||||
- Use `cn()` utility for merging
|
||||
- Base classes first, overrides last
|
||||
@@ -203,24 +208,24 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
const componentVariants = cva(
|
||||
// Base classes (always applied)
|
||||
"inline-flex items-center justify-center rounded-lg font-medium transition-colors",
|
||||
'inline-flex items-center justify-center rounded-lg font-medium transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
sm: "h-8 px-3 text-xs",
|
||||
default: "h-10 px-4 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
default: 'h-10 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -231,25 +236,18 @@ interface MyComponentProps
|
||||
// Additional props here
|
||||
}
|
||||
|
||||
export function MyComponent({
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
...props
|
||||
}: MyComponentProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(componentVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
export function MyComponent({ variant, size, className, ...props }: MyComponentProps) {
|
||||
return <div className={cn(componentVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
// Usage
|
||||
<MyComponent variant="outline" size="lg">Content</MyComponent>
|
||||
<MyComponent variant="outline" size="lg">
|
||||
Content
|
||||
</MyComponent>;
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Use CVA for complex variant logic
|
||||
- Always provide `defaultVariants`
|
||||
- Extend `React.HTMLAttributes` for standard HTML props
|
||||
@@ -273,13 +271,7 @@ interface StatCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
icon,
|
||||
className,
|
||||
}: StatCardProps) {
|
||||
export function StatCard({ title, value, description, icon, className }: StatCardProps) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
@@ -288,9 +280,7 @@ export function StatCard({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -302,10 +292,11 @@ export function StatCard({
|
||||
value="1,234"
|
||||
description="+12% from last month"
|
||||
icon={<Users className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
/>;
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Compose from shadcn/ui primitives
|
||||
- Keep structure consistent
|
||||
- Optional props with `?`
|
||||
@@ -354,14 +345,17 @@ export function Toggle({
|
||||
}
|
||||
|
||||
// Uncontrolled usage
|
||||
<Toggle defaultValue={false}>Auto-save</Toggle>
|
||||
<Toggle defaultValue={false}>Auto-save</Toggle>;
|
||||
|
||||
// Controlled usage
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
<Toggle value={enabled} onChange={setEnabled}>Auto-save</Toggle>
|
||||
<Toggle value={enabled} onChange={setEnabled}>
|
||||
Auto-save
|
||||
</Toggle>;
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Support both controlled and uncontrolled modes
|
||||
- Use `defaultValue` for initial uncontrolled value
|
||||
- Use `value` + `onChange` for controlled mode
|
||||
@@ -376,6 +370,7 @@ const [enabled, setEnabled] = useState(false);
|
||||
**class-variance-authority** (CVA) is a utility for creating component variants with Tailwind CSS.
|
||||
|
||||
**Why use CVA?**
|
||||
|
||||
- ✅ Type-safe variant props
|
||||
- ✅ Compound variants (combinations)
|
||||
- ✅ Default variants
|
||||
@@ -390,24 +385,23 @@ import { cva } from 'class-variance-authority';
|
||||
|
||||
const alertVariants = cva(
|
||||
// Base classes (always applied)
|
||||
"relative w-full rounded-lg border p-4",
|
||||
'relative w-full rounded-lg border p-4',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
default: 'bg-background text-foreground',
|
||||
destructive:
|
||||
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Usage
|
||||
<div className={alertVariants({ variant: "destructive" })}>
|
||||
Alert content
|
||||
</div>
|
||||
<div className={alertVariants({ variant: 'destructive' })}>Alert content</div>;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -416,32 +410,32 @@ const alertVariants = cva(
|
||||
|
||||
```tsx
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-colors",
|
||||
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
sm: "h-8 px-3 text-xs",
|
||||
default: "h-10 px-4 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
default: 'h-10 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Usage
|
||||
<button className={buttonVariants({ variant: "outline", size: "lg" })}>
|
||||
<button className={buttonVariants({ variant: 'outline', size: 'lg' })}>
|
||||
Large Outline Button
|
||||
</button>
|
||||
</button>;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -451,28 +445,28 @@ const buttonVariants = cva(
|
||||
**Use case**: Different classes when specific variant combinations are used
|
||||
|
||||
```tsx
|
||||
const buttonVariants = cva("base-classes", {
|
||||
const buttonVariants = cva('base-classes', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary",
|
||||
destructive: "bg-destructive",
|
||||
default: 'bg-primary',
|
||||
destructive: 'bg-destructive',
|
||||
},
|
||||
size: {
|
||||
sm: "h-8",
|
||||
lg: "h-12",
|
||||
sm: 'h-8',
|
||||
lg: 'h-12',
|
||||
},
|
||||
},
|
||||
// Compound variants: specific combinations
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: "destructive",
|
||||
size: "lg",
|
||||
class: "text-lg font-bold", // Applied when BOTH are true
|
||||
variant: 'destructive',
|
||||
size: 'lg',
|
||||
class: 'text-lg font-bold', // Applied when BOTH are true
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "sm",
|
||||
variant: 'default',
|
||||
size: 'sm',
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -484,6 +478,7 @@ const buttonVariants = cva("base-classes", {
|
||||
### Prop Naming Conventions
|
||||
|
||||
**DO**:
|
||||
|
||||
```tsx
|
||||
// ✅ Descriptive, semantic names
|
||||
interface UserCardProps {
|
||||
@@ -495,6 +490,7 @@ interface UserCardProps {
|
||||
```
|
||||
|
||||
**DON'T**:
|
||||
|
||||
```tsx
|
||||
// ❌ Generic, unclear names
|
||||
interface CardProps {
|
||||
@@ -510,6 +506,7 @@ interface CardProps {
|
||||
### Required vs Optional Props
|
||||
|
||||
**Guidelines:**
|
||||
|
||||
- Required: Core functionality depends on it
|
||||
- Optional: Nice-to-have, has sensible default
|
||||
|
||||
@@ -531,7 +528,7 @@ interface AlertProps {
|
||||
|
||||
export function Alert({
|
||||
children,
|
||||
variant = 'default', // Default for optional prop
|
||||
variant = 'default', // Default for optional prop
|
||||
onClose,
|
||||
icon,
|
||||
className,
|
||||
@@ -545,6 +542,7 @@ export function Alert({
|
||||
### Prop Type Patterns
|
||||
|
||||
**Enum props** (limited options):
|
||||
|
||||
```tsx
|
||||
interface ButtonProps {
|
||||
variant: 'default' | 'destructive' | 'outline';
|
||||
@@ -553,6 +551,7 @@ interface ButtonProps {
|
||||
```
|
||||
|
||||
**Boolean flags**:
|
||||
|
||||
```tsx
|
||||
interface CardProps {
|
||||
isLoading?: boolean;
|
||||
@@ -562,6 +561,7 @@ interface CardProps {
|
||||
```
|
||||
|
||||
**Callback props**:
|
||||
|
||||
```tsx
|
||||
interface FormProps {
|
||||
onSubmit: (data: FormData) => void;
|
||||
@@ -571,6 +571,7 @@ interface FormProps {
|
||||
```
|
||||
|
||||
**Render props** (advanced customization):
|
||||
|
||||
```tsx
|
||||
interface ListProps<T> {
|
||||
items: T[];
|
||||
@@ -583,7 +584,7 @@ interface ListProps<T> {
|
||||
items={users}
|
||||
renderItem={(user, i) => <UserCard key={i} user={user} />}
|
||||
renderEmpty={() => <EmptyState />}
|
||||
/>
|
||||
/>;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -593,6 +594,7 @@ interface ListProps<T> {
|
||||
Before shipping a custom component, verify:
|
||||
|
||||
### Visual Testing
|
||||
|
||||
- [ ] **Light mode** - Component looks correct
|
||||
- [ ] **Dark mode** - Component looks correct (toggle theme)
|
||||
- [ ] **All variants** - Test each variant works
|
||||
@@ -602,6 +604,7 @@ Before shipping a custom component, verify:
|
||||
- [ ] **Empty state** - Handles no data gracefully
|
||||
|
||||
### Accessibility Testing
|
||||
|
||||
- [ ] **Keyboard navigation** - Can be focused and activated with Tab/Enter
|
||||
- [ ] **Focus indicators** - Visible focus ring (`:focus-visible`)
|
||||
- [ ] **Screen reader** - ARIA labels and roles present
|
||||
@@ -609,6 +612,7 @@ Before shipping a custom component, verify:
|
||||
- [ ] **Semantic HTML** - Using correct HTML elements (button, nav, etc.)
|
||||
|
||||
### Functional Testing
|
||||
|
||||
- [ ] **Props work** - All props apply correctly
|
||||
- [ ] **className override** - Can override styles with className prop
|
||||
- [ ] **Controlled/uncontrolled** - Both modes work (if applicable)
|
||||
@@ -616,6 +620,7 @@ Before shipping a custom component, verify:
|
||||
- [ ] **TypeScript** - No type errors, props autocomplete
|
||||
|
||||
### Code Quality
|
||||
|
||||
- [ ] **No console errors** - Check browser console
|
||||
- [ ] **No warnings** - React warnings, a11y warnings
|
||||
- [ ] **Performance** - No unnecessary re-renders
|
||||
@@ -644,13 +649,7 @@ interface StatCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
icon: Icon,
|
||||
className,
|
||||
}: StatCardProps) {
|
||||
export function StatCard({ title, value, change, icon: Icon, className }: StatCardProps) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
@@ -660,11 +659,9 @@ export function StatCard({
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{change !== undefined && (
|
||||
<p className={cn(
|
||||
"text-xs",
|
||||
change >= 0 ? "text-green-600" : "text-destructive"
|
||||
)}>
|
||||
{change >= 0 ? '+' : ''}{change}% from last month
|
||||
<p className={cn('text-xs', change >= 0 ? 'text-green-600' : 'text-destructive')}>
|
||||
{change >= 0 ? '+' : ''}
|
||||
{change}% from last month
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -678,10 +675,11 @@ export function StatCard({
|
||||
<StatCard title="Subscriptions" value="+2350" change={12.5} icon={Users} />
|
||||
<StatCard title="Sales" value="+12,234" change={19} icon={CreditCard} />
|
||||
<StatCard title="Active Now" value="+573" change={-2.1} icon={Activity} />
|
||||
</div>
|
||||
</div>;
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
|
||||
- Specific purpose (dashboard metrics)
|
||||
- Reused 8+ times
|
||||
- Consistent structure
|
||||
@@ -747,18 +745,10 @@ export function ConfirmDialog({
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Button variant={variant} onClick={handleConfirm} disabled={isLoading}>
|
||||
{isLoading ? 'Processing...' : confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -781,10 +771,11 @@ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
await deleteUser(user.id);
|
||||
toast.success('User deleted');
|
||||
}}
|
||||
/>
|
||||
/>;
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
|
||||
- Common pattern (confirmations)
|
||||
- Handles loading states automatically
|
||||
- Consistent UX across app
|
||||
@@ -808,19 +799,12 @@ interface PageHeaderProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: PageHeaderProps) {
|
||||
export function PageHeader({ title, description, action, className }: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between", className)}>
|
||||
<div className={cn('flex items-center justify-between', className)}>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{description && <p className="text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
@@ -837,7 +821,7 @@ export function PageHeader({
|
||||
Create User
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
/>;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -866,6 +850,7 @@ Before creating a custom component, ask:
|
||||
---
|
||||
|
||||
**Related Documentation:**
|
||||
|
||||
- [Components](./02-components.md) - shadcn/ui component library
|
||||
- [AI Guidelines](./08-ai-guidelines.md) - Component templates for AI
|
||||
- [Forms](./06-forms.md) - Form component patterns
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
- **shadcn/ui components** - Input, Label, Button, etc.
|
||||
|
||||
**Why this stack?**
|
||||
|
||||
- ✅ Type-safe validation (TypeScript + Zod)
|
||||
- ✅ Minimal re-renders (react-hook-form)
|
||||
- ✅ Accessible by default (shadcn/ui)
|
||||
@@ -80,11 +81,7 @@ export function SimpleForm() {
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
{...form.register('email')}
|
||||
/>
|
||||
<Input id="email" type="email" {...form.register('email')} />
|
||||
</div>
|
||||
|
||||
<Button type="submit">Submit</Button>
|
||||
@@ -180,6 +177,7 @@ export function LoginForm() {
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
1. Define Zod schema first
|
||||
2. Infer TypeScript type with `z.infer`
|
||||
3. Use `zodResolver` in `useForm`
|
||||
@@ -217,15 +215,9 @@ export function LoginForm() {
|
||||
```tsx
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
rows={4}
|
||||
{...form.register('description')}
|
||||
/>
|
||||
<Textarea id="description" rows={4} {...form.register('description')} />
|
||||
{form.formState.errors.description && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.description.message}
|
||||
</p>
|
||||
<p className="text-sm text-destructive">{form.formState.errors.description.message}</p>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
@@ -237,10 +229,7 @@ export function LoginForm() {
|
||||
```tsx
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Select
|
||||
value={form.watch('role')}
|
||||
onValueChange={(value) => form.setValue('role', value)}
|
||||
>
|
||||
<Select value={form.watch('role')} onValueChange={(value) => form.setValue('role', value)}>
|
||||
<SelectTrigger id="role">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
@@ -251,9 +240,7 @@ export function LoginForm() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.role && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.role.message}
|
||||
</p>
|
||||
<p className="text-sm text-destructive">{form.formState.errors.role.message}</p>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
@@ -272,12 +259,12 @@ export function LoginForm() {
|
||||
<Label htmlFor="terms" className="text-sm font-normal">
|
||||
I accept the terms and conditions
|
||||
</Label>
|
||||
</div>
|
||||
{form.formState.errors.acceptTerms && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.acceptTerms.message}
|
||||
</p>
|
||||
)}
|
||||
</div>;
|
||||
{
|
||||
form.formState.errors.acceptTerms && (
|
||||
<p className="text-sm text-destructive">{form.formState.errors.acceptTerms.message}</p>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -289,22 +276,16 @@ export function LoginForm() {
|
||||
<Label>Notification Method</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="email"
|
||||
value="email"
|
||||
{...form.register('notificationMethod')}
|
||||
/>
|
||||
<Label htmlFor="email" className="font-normal">Email</Label>
|
||||
<input type="radio" id="email" value="email" {...form.register('notificationMethod')} />
|
||||
<Label htmlFor="email" className="font-normal">
|
||||
Email
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="sms"
|
||||
value="sms"
|
||||
{...form.register('notificationMethod')}
|
||||
/>
|
||||
<Label htmlFor="sms" className="font-normal">SMS</Label>
|
||||
<input type="radio" id="sms" value="sms" {...form.register('notificationMethod')} />
|
||||
<Label htmlFor="sms" className="font-normal">
|
||||
SMS
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -320,65 +301,68 @@ export function LoginForm() {
|
||||
import { z } from 'zod';
|
||||
|
||||
// Email
|
||||
z.string().email('Invalid email address')
|
||||
z.string().email('Invalid email address');
|
||||
|
||||
// Min/max length
|
||||
z.string().min(8, 'Minimum 8 characters').max(100, 'Maximum 100 characters')
|
||||
z.string().min(8, 'Minimum 8 characters').max(100, 'Maximum 100 characters');
|
||||
|
||||
// Required field
|
||||
z.string().min(1, 'This field is required')
|
||||
z.string().min(1, 'This field is required');
|
||||
|
||||
// Optional field
|
||||
z.string().optional()
|
||||
z.string().optional();
|
||||
|
||||
// Number with range
|
||||
z.number().min(0).max(100)
|
||||
z.number().min(0).max(100);
|
||||
|
||||
// Number from string input
|
||||
z.coerce.number().min(0)
|
||||
z.coerce.number().min(0);
|
||||
|
||||
// Enum
|
||||
z.enum(['admin', 'user', 'guest'], {
|
||||
errorMap: () => ({ message: 'Invalid role' })
|
||||
})
|
||||
errorMap: () => ({ message: 'Invalid role' }),
|
||||
});
|
||||
|
||||
// URL
|
||||
z.string().url('Invalid URL')
|
||||
z.string().url('Invalid URL');
|
||||
|
||||
// Password with requirements
|
||||
z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number');
|
||||
|
||||
// Confirm password
|
||||
z.object({
|
||||
password: z.string().min(8),
|
||||
confirmPassword: z.string()
|
||||
confirmPassword: z.string(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
// Custom validation
|
||||
z.string().refine((val) => !val.includes('badword'), {
|
||||
message: 'Invalid input',
|
||||
})
|
||||
});
|
||||
|
||||
// Conditional fields
|
||||
z.object({
|
||||
role: z.enum(['admin', 'user']),
|
||||
adminKey: z.string().optional(),
|
||||
}).refine((data) => {
|
||||
if (data.role === 'admin') {
|
||||
return !!data.adminKey;
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.role === 'admin') {
|
||||
return !!data.adminKey;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Admin key required for admin role',
|
||||
path: ['adminKey'],
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
message: 'Admin key required for admin role',
|
||||
path: ['adminKey'],
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
@@ -448,6 +432,7 @@ type UserFormData = z.infer<typeof userFormSchema>;
|
||||
```
|
||||
|
||||
**Accessibility notes:**
|
||||
|
||||
- Use `aria-invalid` to indicate error state
|
||||
- Use `aria-describedby` to link error message
|
||||
- Error ID format: `{fieldName}-error`
|
||||
@@ -470,14 +455,14 @@ const onSubmit = async (data: FormData) => {
|
||||
};
|
||||
|
||||
// Display form-level error
|
||||
{form.formState.errors.root && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{form.formState.errors.root.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{
|
||||
form.formState.errors.root && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{form.formState.errors.root.message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -620,32 +605,26 @@ const onSubmit = async (data: FormData) => {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Personal Information</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Basic details about you
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Basic details about you</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
{/* Fields */}
|
||||
</div>
|
||||
<div className="space-y-4">{/* Fields */}</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2 */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Account Settings</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure your account preferences
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Configure your account preferences</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
{/* Fields */}
|
||||
</div>
|
||||
<div className="space-y-4">{/* Fields */}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button type="button" variant="outline">Cancel</Button>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -661,10 +640,14 @@ const onSubmit = async (data: FormData) => {
|
||||
import { useFieldArray } from 'react-hook-form';
|
||||
|
||||
const schema = z.object({
|
||||
items: z.array(z.object({
|
||||
name: z.string().min(1),
|
||||
quantity: z.coerce.number().min(1),
|
||||
})).min(1, 'At least one item required'),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
quantity: z.coerce.number().min(1),
|
||||
})
|
||||
)
|
||||
.min(1, 'At least one item required'),
|
||||
});
|
||||
|
||||
function DynamicForm() {
|
||||
@@ -684,30 +667,19 @@ function DynamicForm() {
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-4">
|
||||
<Input
|
||||
{...form.register(`items.${index}.name`)}
|
||||
placeholder="Item name"
|
||||
/>
|
||||
<Input {...form.register(`items.${index}.name`)} placeholder="Item name" />
|
||||
<Input
|
||||
type="number"
|
||||
{...form.register(`items.${index}.quantity`)}
|
||||
placeholder="Quantity"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Button type="button" variant="destructive" onClick={() => remove(index)}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => append({ name: '', quantity: 1 })}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => append({ name: '', quantity: 1 })}>
|
||||
Add Item
|
||||
</Button>
|
||||
|
||||
@@ -722,18 +694,23 @@ function DynamicForm() {
|
||||
### Conditional Fields
|
||||
|
||||
```tsx
|
||||
const schema = z.object({
|
||||
role: z.enum(['user', 'admin']),
|
||||
adminKey: z.string().optional(),
|
||||
}).refine((data) => {
|
||||
if (data.role === 'admin') {
|
||||
return !!data.adminKey;
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
message: 'Admin key required',
|
||||
path: ['adminKey'],
|
||||
});
|
||||
const schema = z
|
||||
.object({
|
||||
role: z.enum(['user', 'admin']),
|
||||
adminKey: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.role === 'admin') {
|
||||
return !!data.adminKey;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Admin key required',
|
||||
path: ['adminKey'],
|
||||
}
|
||||
);
|
||||
|
||||
function ConditionalForm() {
|
||||
const form = useForm({ resolver: zodResolver(schema) });
|
||||
@@ -741,23 +718,17 @@ function ConditionalForm() {
|
||||
|
||||
return (
|
||||
<form className="space-y-4">
|
||||
<Select
|
||||
value={role}
|
||||
onValueChange={(val) => form.setValue('role', val as any)}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<Select value={role} onValueChange={(val) => form.setValue('role', val as any)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{role === 'admin' && (
|
||||
<Input
|
||||
{...form.register('adminKey')}
|
||||
placeholder="Admin Key"
|
||||
/>
|
||||
)}
|
||||
{role === 'admin' && <Input {...form.register('adminKey')} placeholder="Admin Key" />}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -774,14 +745,10 @@ const schema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
<input
|
||||
type="file"
|
||||
{...form.register('file')}
|
||||
accept="image/*"
|
||||
/>
|
||||
<input type="file" {...form.register('file')} accept="image/*" />;
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
const file = data.file[0]; // FileList -> File
|
||||
const file = data.file[0]; // FileList -> File
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
// Upload formData
|
||||
@@ -795,6 +762,7 @@ const onSubmit = (data: FormData) => {
|
||||
Before shipping a form, verify:
|
||||
|
||||
### Functionality
|
||||
|
||||
- [ ] All fields register correctly
|
||||
- [ ] Validation works (test invalid inputs)
|
||||
- [ ] Submit handler fires
|
||||
@@ -803,6 +771,7 @@ Before shipping a form, verify:
|
||||
- [ ] Success case redirects/shows success
|
||||
|
||||
### Accessibility
|
||||
|
||||
- [ ] Labels associated with inputs (`htmlFor` + `id`)
|
||||
- [ ] Error messages use `aria-describedby`
|
||||
- [ ] Invalid inputs have `aria-invalid`
|
||||
@@ -810,6 +779,7 @@ Before shipping a form, verify:
|
||||
- [ ] Submit button disabled during submission
|
||||
|
||||
### UX
|
||||
|
||||
- [ ] Field errors appear on blur or submit
|
||||
- [ ] Loading state prevents double-submit
|
||||
- [ ] Success message or redirect on success
|
||||
@@ -827,11 +797,13 @@ Before shipping a form, verify:
|
||||
---
|
||||
|
||||
**Related Documentation:**
|
||||
|
||||
- [Components](./02-components.md) - Input, Label, Button, Select
|
||||
- [Layouts](./03-layouts.md) - Form layout patterns
|
||||
- [Accessibility](./07-accessibility.md) - ARIA attributes for forms
|
||||
|
||||
**External Resources:**
|
||||
|
||||
- [react-hook-form Documentation](https://react-hook-form.com)
|
||||
- [Zod Documentation](https://zod.dev)
|
||||
|
||||
|
||||
@@ -24,12 +24,14 @@
|
||||
We follow **WCAG 2.1 Level AA** as the **minimum** standard.
|
||||
|
||||
**Why Level AA?**
|
||||
|
||||
- ✅ Required for most legal compliance (ADA, Section 508)
|
||||
- ✅ Covers 95%+ of accessibility needs
|
||||
- ✅ Achievable without major UX compromises
|
||||
- ✅ Industry standard for modern web apps
|
||||
|
||||
**WCAG Principles (POUR):**
|
||||
|
||||
1. **Perceivable** - Information can be perceived by users
|
||||
2. **Operable** - Interface can be operated by users
|
||||
3. **Understandable** - Information and operation are understandable
|
||||
@@ -63,14 +65,15 @@ Creating a UI element?
|
||||
|
||||
### Minimum Contrast Ratios (WCAG AA)
|
||||
|
||||
| Content Type | Minimum Ratio | Example |
|
||||
|--------------|---------------|---------|
|
||||
| **Normal text** (< 18px) | **4.5:1** | Body paragraphs, form labels |
|
||||
| **Large text** (≥ 18px or ≥ 14px bold) | **3:1** | Headings, subheadings |
|
||||
| **UI components** | **3:1** | Buttons, form borders, icons |
|
||||
| **Graphical objects** | **3:1** | Chart elements, infographics |
|
||||
| Content Type | Minimum Ratio | Example |
|
||||
| -------------------------------------- | ------------- | ---------------------------- |
|
||||
| **Normal text** (< 18px) | **4.5:1** | Body paragraphs, form labels |
|
||||
| **Large text** (≥ 18px or ≥ 14px bold) | **3:1** | Headings, subheadings |
|
||||
| **UI components** | **3:1** | Buttons, form borders, icons |
|
||||
| **Graphical objects** | **3:1** | Chart elements, infographics |
|
||||
|
||||
**WCAG AAA (ideal, not required):**
|
||||
|
||||
- Normal text: 7:1
|
||||
- Large text: 4.5:1
|
||||
|
||||
@@ -79,6 +82,7 @@ Creating a UI element?
|
||||
### Testing Color Contrast
|
||||
|
||||
**Tools:**
|
||||
|
||||
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
|
||||
- Chrome DevTools: Inspect element → Accessibility panel
|
||||
- [Contrast Ratio Tool](https://contrast-ratio.com)
|
||||
@@ -104,6 +108,7 @@ Creating a UI element?
|
||||
```
|
||||
|
||||
**Our design system tokens are WCAG AA compliant:**
|
||||
|
||||
- `text-foreground` on `bg-background`: 12.6:1 ✅
|
||||
- `text-primary-foreground` on `bg-primary`: 8.2:1 ✅
|
||||
- `text-destructive` on `bg-background`: 5.1:1 ✅
|
||||
@@ -116,6 +121,7 @@ Creating a UI element?
|
||||
**8% of men and 0.5% of women** have some form of color blindness.
|
||||
|
||||
**Best practices:**
|
||||
|
||||
- ❌ Don't rely on color alone to convey information
|
||||
- ✅ Use icons, text labels, or patterns in addition to color
|
||||
- ✅ Test with color blindness simulators
|
||||
@@ -148,6 +154,7 @@ Creating a UI element?
|
||||
### Core Requirements
|
||||
|
||||
All interactive elements must be:
|
||||
|
||||
1. ✅ **Focusable** - Can be reached with Tab key
|
||||
2. ✅ **Activatable** - Can be triggered with Enter or Space
|
||||
3. ✅ **Navigable** - Can move between with arrow keys (where appropriate)
|
||||
@@ -176,6 +183,7 @@ All interactive elements must be:
|
||||
```
|
||||
|
||||
**When to use `tabIndex`:**
|
||||
|
||||
- `tabIndex={0}` - Make non-interactive element focusable
|
||||
- `tabIndex={-1}` - Remove from tab order (for programmatic focus)
|
||||
- `tabIndex={1+}` - ❌ **Avoid** - Breaks natural order
|
||||
@@ -184,31 +192,31 @@ All interactive elements must be:
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Key | Action | Example |
|
||||
|-----|--------|---------|
|
||||
| **Tab** | Move focus forward | Navigate through form fields |
|
||||
| **Shift + Tab** | Move focus backward | Go back to previous field |
|
||||
| **Enter** | Activate button/link | Submit form, follow link |
|
||||
| **Space** | Activate button/checkbox | Toggle checkbox, click button |
|
||||
| **Escape** | Close overlay | Close dialog, dropdown |
|
||||
| **Arrow keys** | Navigate within component | Navigate dropdown items |
|
||||
| **Home** | Jump to start | First item in list |
|
||||
| **End** | Jump to end | Last item in list |
|
||||
| Key | Action | Example |
|
||||
| --------------- | ------------------------- | ----------------------------- |
|
||||
| **Tab** | Move focus forward | Navigate through form fields |
|
||||
| **Shift + Tab** | Move focus backward | Go back to previous field |
|
||||
| **Enter** | Activate button/link | Submit form, follow link |
|
||||
| **Space** | Activate button/checkbox | Toggle checkbox, click button |
|
||||
| **Escape** | Close overlay | Close dialog, dropdown |
|
||||
| **Arrow keys** | Navigate within component | Navigate dropdown items |
|
||||
| **Home** | Jump to start | First item in list |
|
||||
| **End** | Jump to end | Last item in list |
|
||||
|
||||
---
|
||||
|
||||
### Implementing Keyboard Navigation
|
||||
|
||||
**Button (automatic):**
|
||||
|
||||
```tsx
|
||||
// ✅ Button is keyboard accessible by default
|
||||
<Button onClick={handleClick}>
|
||||
Click me
|
||||
</Button>
|
||||
<Button onClick={handleClick}>Click me</Button>
|
||||
// Enter or Space triggers onClick
|
||||
```
|
||||
|
||||
**Custom clickable div (needs work):**
|
||||
|
||||
```tsx
|
||||
// ❌ BAD - Not keyboard accessible
|
||||
<div onClick={handleClick}>
|
||||
@@ -237,11 +245,12 @@ All interactive elements must be:
|
||||
```
|
||||
|
||||
**Dropdown navigation:**
|
||||
|
||||
```tsx
|
||||
<DropdownMenu>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem> {/* Arrow down */}
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem> {/* Arrow down */}
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem> {/* Arrow down */}
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem> {/* Arrow down */}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
// shadcn/ui handles arrow key navigation automatically
|
||||
@@ -276,12 +285,14 @@ All interactive elements must be:
|
||||
### Screen Reader Basics
|
||||
|
||||
**Popular screen readers:**
|
||||
|
||||
- **NVDA** (Windows) - Free, most popular for testing
|
||||
- **JAWS** (Windows) - Industry standard, paid
|
||||
- **VoiceOver** (macOS/iOS) - Built-in to Apple devices
|
||||
- **TalkBack** (Android) - Built-in to Android
|
||||
|
||||
**What screen readers announce:**
|
||||
|
||||
- Semantic element type (button, link, heading, etc.)
|
||||
- Element text content
|
||||
- Element state (expanded, selected, disabled)
|
||||
@@ -334,6 +345,7 @@ All interactive elements must be:
|
||||
```
|
||||
|
||||
**Semantic elements:**
|
||||
|
||||
- `<header>` - Page header
|
||||
- `<nav>` - Navigation
|
||||
- `<main>` - Main content (only one per page)
|
||||
@@ -364,6 +376,7 @@ All interactive elements must be:
|
||||
```
|
||||
|
||||
**Icon-only buttons:**
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD - ARIA label
|
||||
<Button size="icon" aria-label="Close dialog">
|
||||
@@ -383,6 +396,7 @@ All interactive elements must be:
|
||||
### Common ARIA Attributes
|
||||
|
||||
**ARIA roles:**
|
||||
|
||||
```tsx
|
||||
<div role="button" tabIndex={0}>Custom Button</div>
|
||||
<div role="alert">Error message</div>
|
||||
@@ -391,6 +405,7 @@ All interactive elements must be:
|
||||
```
|
||||
|
||||
**ARIA states:**
|
||||
|
||||
```tsx
|
||||
<button aria-expanded={isOpen}>Toggle Menu</button>
|
||||
<button aria-pressed={isActive}>Toggle</button>
|
||||
@@ -399,6 +414,7 @@ All interactive elements must be:
|
||||
```
|
||||
|
||||
**ARIA properties:**
|
||||
|
||||
```tsx
|
||||
<button aria-label="Close">×</button>
|
||||
<input aria-describedby="email-help" />
|
||||
@@ -412,6 +428,7 @@ All interactive elements must be:
|
||||
### Form Accessibility
|
||||
|
||||
**Label association:**
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD - Explicit association
|
||||
<Label htmlFor="email">Email</Label>
|
||||
@@ -423,6 +440,7 @@ All interactive elements must be:
|
||||
```
|
||||
|
||||
**Error messages:**
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD - Linked with aria-describedby
|
||||
<Label htmlFor="password">Password</Label>
|
||||
@@ -444,6 +462,7 @@ All interactive elements must be:
|
||||
```
|
||||
|
||||
**Required fields:**
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD - Marked as required
|
||||
<Label htmlFor="name">
|
||||
@@ -502,6 +521,7 @@ toast.success('User created');
|
||||
```
|
||||
|
||||
**Use `:focus-visible` instead of `:focus`:**
|
||||
|
||||
- `:focus` - Shows on mouse click AND keyboard
|
||||
- `:focus-visible` - Shows only on keyboard (better UX)
|
||||
|
||||
@@ -516,7 +536,7 @@ toast.success('User created');
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
{/* Focus trapped inside */}
|
||||
<Input autoFocus /> {/* Focus first field */}
|
||||
<Input autoFocus /> {/* Focus first field */}
|
||||
<Button>Submit</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -539,7 +559,7 @@ const handleDelete = () => {
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
<Input ref={inputRef} />
|
||||
<Input ref={inputRef} />;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -549,11 +569,13 @@ const handleDelete = () => {
|
||||
### Automated Testing Tools
|
||||
|
||||
**Browser extensions:**
|
||||
|
||||
- [axe DevTools](https://www.deque.com/axe/devtools/) - Free, comprehensive
|
||||
- [WAVE](https://wave.webaim.org/extension/) - Visual feedback
|
||||
- [Lighthouse](https://developer.chrome.com/docs/lighthouse/) - Built into Chrome
|
||||
|
||||
**CI/CD testing:**
|
||||
|
||||
- [@axe-core/react](https://github.com/dequelabs/axe-core-npm/tree/develop/packages/react) - Runtime accessibility testing
|
||||
- [jest-axe](https://github.com/nickcolley/jest-axe) - Jest integration
|
||||
- [Playwright accessibility testing](https://playwright.dev/docs/accessibility-testing)
|
||||
@@ -563,6 +585,7 @@ const handleDelete = () => {
|
||||
### Manual Testing Checklist
|
||||
|
||||
#### Keyboard Testing
|
||||
|
||||
1. [ ] Unplug mouse
|
||||
2. [ ] Tab through entire page
|
||||
3. [ ] All interactive elements focusable?
|
||||
@@ -572,6 +595,7 @@ const handleDelete = () => {
|
||||
7. [ ] Tab order logical?
|
||||
|
||||
#### Screen Reader Testing
|
||||
|
||||
1. [ ] Install NVDA (Windows) or VoiceOver (Mac)
|
||||
2. [ ] Navigate page with screen reader on
|
||||
3. [ ] All content announced?
|
||||
@@ -580,6 +604,7 @@ const handleDelete = () => {
|
||||
6. [ ] Heading hierarchy correct?
|
||||
|
||||
#### Contrast Testing
|
||||
|
||||
1. [ ] Use contrast checker on all text
|
||||
2. [ ] Check UI components (buttons, borders)
|
||||
3. [ ] Test in dark mode too
|
||||
@@ -590,6 +615,7 @@ const handleDelete = () => {
|
||||
### Testing with Real Users
|
||||
|
||||
**Considerations:**
|
||||
|
||||
- Test with actual users who rely on assistive technologies
|
||||
- Different screen readers behave differently
|
||||
- Mobile screen readers (VoiceOver, TalkBack) differ from desktop
|
||||
@@ -600,6 +626,7 @@ const handleDelete = () => {
|
||||
## Accessibility Checklist
|
||||
|
||||
### General
|
||||
|
||||
- [ ] Page has `<title>` and `<meta name="description">`
|
||||
- [ ] Page has proper heading hierarchy (h1 → h2 → h3)
|
||||
- [ ] Landmarks used (`<header>`, `<nav>`, `<main>`, `<footer>`)
|
||||
@@ -607,12 +634,14 @@ const handleDelete = () => {
|
||||
- [ ] No content relies on color alone
|
||||
|
||||
### Color & Contrast
|
||||
|
||||
- [ ] Text has 4.5:1 contrast (normal) or 3:1 (large)
|
||||
- [ ] UI components have 3:1 contrast
|
||||
- [ ] Tested in both light and dark modes
|
||||
- [ ] Color blindness simulator used
|
||||
|
||||
### Keyboard
|
||||
|
||||
- [ ] All interactive elements focusable
|
||||
- [ ] Focus indicators visible (ring, outline, etc.)
|
||||
- [ ] Tab order is logical
|
||||
@@ -622,6 +651,7 @@ const handleDelete = () => {
|
||||
- [ ] Arrow keys navigate lists/menus
|
||||
|
||||
### Screen Readers
|
||||
|
||||
- [ ] All images have alt text
|
||||
- [ ] Icon-only buttons have aria-label
|
||||
- [ ] Form labels associated with inputs
|
||||
@@ -631,6 +661,7 @@ const handleDelete = () => {
|
||||
- [ ] ARIA roles used correctly
|
||||
|
||||
### Forms
|
||||
|
||||
- [ ] Labels associated with inputs (`htmlFor` + `id`)
|
||||
- [ ] Error messages linked (`aria-describedby`)
|
||||
- [ ] Invalid inputs marked (`aria-invalid`)
|
||||
@@ -638,6 +669,7 @@ const handleDelete = () => {
|
||||
- [ ] Submit button disabled during submission
|
||||
|
||||
### Focus Management
|
||||
|
||||
- [ ] Dialogs trap focus
|
||||
- [ ] Focus returns after dialog closes
|
||||
- [ ] Programmatic focus after actions
|
||||
@@ -650,29 +682,36 @@ const handleDelete = () => {
|
||||
**Easy improvements with big impact:**
|
||||
|
||||
1. **Add alt text to images**
|
||||
|
||||
```tsx
|
||||
<img src="/logo.png" alt="Company Logo" />
|
||||
```
|
||||
|
||||
2. **Associate labels with inputs**
|
||||
|
||||
```tsx
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" />
|
||||
```
|
||||
|
||||
3. **Use semantic HTML**
|
||||
|
||||
```tsx
|
||||
<button> instead of <div onClick>
|
||||
```
|
||||
|
||||
4. **Add aria-label to icon buttons**
|
||||
|
||||
```tsx
|
||||
<Button aria-label="Close"><X /></Button>
|
||||
<Button aria-label="Close">
|
||||
<X />
|
||||
</Button>
|
||||
```
|
||||
|
||||
5. **Use semantic color tokens**
|
||||
|
||||
```tsx
|
||||
className="text-foreground" // Auto contrast
|
||||
className = 'text-foreground'; // Auto contrast
|
||||
```
|
||||
|
||||
6. **Test with keyboard only**
|
||||
@@ -691,11 +730,13 @@ const handleDelete = () => {
|
||||
---
|
||||
|
||||
**Related Documentation:**
|
||||
|
||||
- [Forms](./06-forms.md) - Accessible form patterns
|
||||
- [Components](./02-components.md) - All components are accessible
|
||||
- [Foundations](./01-foundations.md) - Color contrast tokens
|
||||
|
||||
**External Resources:**
|
||||
|
||||
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
|
||||
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
|
||||
- [A11y Project Checklist](https://www.a11yproject.com/checklist/)
|
||||
|
||||
@@ -9,19 +9,22 @@
|
||||
### ALWAYS Do
|
||||
|
||||
1. ✅ **Import from `@/components/ui/*`**
|
||||
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
```
|
||||
|
||||
2. ✅ **Use semantic color tokens**
|
||||
|
||||
```tsx
|
||||
className="bg-primary text-primary-foreground"
|
||||
className="text-destructive"
|
||||
className="bg-muted text-muted-foreground"
|
||||
className = 'bg-primary text-primary-foreground';
|
||||
className = 'text-destructive';
|
||||
className = 'bg-muted text-muted-foreground';
|
||||
```
|
||||
|
||||
3. ✅ **Use `cn()` utility for className merging**
|
||||
|
||||
```tsx
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -29,11 +32,13 @@
|
||||
```
|
||||
|
||||
4. ✅ **Follow spacing scale** (multiples of 4: 0, 1, 2, 3, 4, 6, 8, 12, 16)
|
||||
|
||||
```tsx
|
||||
className="p-4 space-y-6 mb-8"
|
||||
className = 'p-4 space-y-6 mb-8';
|
||||
```
|
||||
|
||||
5. ✅ **Add accessibility attributes**
|
||||
|
||||
```tsx
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
@@ -44,12 +49,14 @@
|
||||
```
|
||||
|
||||
6. ✅ **Use component variants**
|
||||
|
||||
```tsx
|
||||
<Button variant="destructive">Delete</Button>
|
||||
<Alert variant="destructive">Error message</Alert>
|
||||
```
|
||||
|
||||
7. ✅ **Compose from shadcn/ui primitives**
|
||||
|
||||
```tsx
|
||||
// Don't create custom card components
|
||||
// Use Card + CardHeader + CardTitle + CardContent
|
||||
@@ -57,8 +64,8 @@
|
||||
|
||||
8. ✅ **Use mobile-first responsive design**
|
||||
```tsx
|
||||
className="text-2xl sm:text-3xl lg:text-4xl"
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
|
||||
className = 'text-2xl sm:text-3xl lg:text-4xl';
|
||||
className = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3';
|
||||
```
|
||||
|
||||
---
|
||||
@@ -66,24 +73,27 @@
|
||||
### NEVER Do
|
||||
|
||||
1. ❌ **NO arbitrary colors**
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG
|
||||
className="bg-blue-500 text-white"
|
||||
className = 'bg-blue-500 text-white';
|
||||
|
||||
// ✅ CORRECT
|
||||
className="bg-primary text-primary-foreground"
|
||||
className = 'bg-primary text-primary-foreground';
|
||||
```
|
||||
|
||||
2. ❌ **NO arbitrary spacing values**
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG
|
||||
className="p-[13px] mb-[17px]"
|
||||
className = 'p-[13px] mb-[17px]';
|
||||
|
||||
// ✅ CORRECT
|
||||
className="p-4 mb-4"
|
||||
className = 'p-4 mb-4';
|
||||
```
|
||||
|
||||
3. ❌ **NO inline styles**
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG
|
||||
style={{ margin: '10px', color: '#3b82f6' }}
|
||||
@@ -93,6 +103,7 @@
|
||||
```
|
||||
|
||||
4. ❌ **NO custom CSS classes** (use Tailwind utilities)
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG
|
||||
<div className="my-custom-class">
|
||||
@@ -102,6 +113,7 @@
|
||||
```
|
||||
|
||||
5. ❌ **NO mixing component libraries**
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG - Don't use Material-UI, Ant Design, etc.
|
||||
import { Button } from '@mui/material';
|
||||
@@ -111,6 +123,7 @@
|
||||
```
|
||||
|
||||
6. ❌ **NO skipping accessibility**
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG
|
||||
<button><X /></button>
|
||||
@@ -122,6 +135,7 @@
|
||||
```
|
||||
|
||||
7. ❌ **NO creating custom variants without CVA**
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG
|
||||
<Button className={type === 'danger' ? 'bg-red-500' : 'bg-blue-500'}>
|
||||
@@ -138,9 +152,7 @@
|
||||
|
||||
```tsx
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Content */}
|
||||
</div>
|
||||
<div className="max-w-4xl mx-auto space-y-6">{/* Content */}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
@@ -148,7 +160,9 @@
|
||||
|
||||
```tsx
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{items.map(item => <Card key={item.id}>...</Card>)}
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>...</Card>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
@@ -160,9 +174,7 @@
|
||||
<CardTitle>Form Title</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4">
|
||||
{/* Form fields */}
|
||||
</form>
|
||||
<form className="space-y-4">{/* Form fields */}</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
@@ -170,9 +182,7 @@
|
||||
### Centered Content
|
||||
|
||||
```tsx
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
{/* Readable content width */}
|
||||
</div>
|
||||
<div className="max-w-2xl mx-auto px-4">{/* Readable content width */}</div>
|
||||
```
|
||||
|
||||
---
|
||||
@@ -191,17 +201,15 @@ interface MyComponentProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MyComponent({
|
||||
variant = 'default',
|
||||
className,
|
||||
children
|
||||
}: MyComponentProps) {
|
||||
export function MyComponent({ variant = 'default', className, children }: MyComponentProps) {
|
||||
return (
|
||||
<Card className={cn(
|
||||
"p-4", // base styles
|
||||
variant === 'compact' && "p-2",
|
||||
className // allow overrides
|
||||
)}>
|
||||
<Card
|
||||
className={cn(
|
||||
'p-4', // base styles
|
||||
variant === 'compact' && 'p-2',
|
||||
className // allow overrides
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
@@ -215,22 +223,22 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const componentVariants = cva(
|
||||
"base-classes-here", // base
|
||||
'base-classes-here', // base
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground",
|
||||
destructive: "bg-destructive text-destructive-foreground",
|
||||
default: 'bg-primary text-primary-foreground',
|
||||
destructive: 'bg-destructive text-destructive-foreground',
|
||||
},
|
||||
size: {
|
||||
sm: "h-8 px-3 text-xs",
|
||||
default: "h-10 px-4 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
default: 'h-10 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -240,9 +248,7 @@ interface ComponentProps
|
||||
VariantProps<typeof componentVariants> {}
|
||||
|
||||
export function Component({ variant, size, className, ...props }: ComponentProps) {
|
||||
return (
|
||||
<div className={cn(componentVariants({ variant, size, className }))} {...props} />
|
||||
);
|
||||
return <div className={cn(componentVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -313,18 +319,18 @@ export function MyForm() {
|
||||
|
||||
**Always use these semantic tokens:**
|
||||
|
||||
| Token | Usage |
|
||||
|-------|-------|
|
||||
| `bg-primary text-primary-foreground` | Primary buttons, CTAs |
|
||||
| `bg-secondary text-secondary-foreground` | Secondary actions |
|
||||
| `bg-destructive text-destructive-foreground` | Delete, errors |
|
||||
| `bg-muted text-muted-foreground` | Disabled states |
|
||||
| `bg-accent text-accent-foreground` | Hover states |
|
||||
| `bg-card text-card-foreground` | Card backgrounds |
|
||||
| `text-foreground` | Body text |
|
||||
| `text-muted-foreground` | Secondary text |
|
||||
| `border-border` | Borders |
|
||||
| `ring-ring` | Focus rings |
|
||||
| Token | Usage |
|
||||
| -------------------------------------------- | --------------------- |
|
||||
| `bg-primary text-primary-foreground` | Primary buttons, CTAs |
|
||||
| `bg-secondary text-secondary-foreground` | Secondary actions |
|
||||
| `bg-destructive text-destructive-foreground` | Delete, errors |
|
||||
| `bg-muted text-muted-foreground` | Disabled states |
|
||||
| `bg-accent text-accent-foreground` | Hover states |
|
||||
| `bg-card text-card-foreground` | Card backgrounds |
|
||||
| `text-foreground` | Body text |
|
||||
| `text-muted-foreground` | Secondary text |
|
||||
| `border-border` | Borders |
|
||||
| `ring-ring` | Focus rings |
|
||||
|
||||
---
|
||||
|
||||
@@ -332,14 +338,14 @@ export function MyForm() {
|
||||
|
||||
**Use these spacing values (multiples of 4px):**
|
||||
|
||||
| Class | Value | Pixels | Usage |
|
||||
|-------|-------|--------|-------|
|
||||
| `2` | 0.5rem | 8px | Tight spacing |
|
||||
| `4` | 1rem | 16px | Standard spacing |
|
||||
| `6` | 1.5rem | 24px | Section spacing |
|
||||
| `8` | 2rem | 32px | Large gaps |
|
||||
| `12` | 3rem | 48px | Section dividers |
|
||||
| `16` | 4rem | 64px | Page sections |
|
||||
| Class | Value | Pixels | Usage |
|
||||
| ----- | ------ | ------ | ---------------- |
|
||||
| `2` | 0.5rem | 8px | Tight spacing |
|
||||
| `4` | 1rem | 16px | Standard spacing |
|
||||
| `6` | 1.5rem | 24px | Section spacing |
|
||||
| `8` | 2rem | 32px | Large gaps |
|
||||
| `12` | 3rem | 48px | Section dividers |
|
||||
| `16` | 4rem | 64px | Page sections |
|
||||
|
||||
---
|
||||
|
||||
@@ -428,7 +434,7 @@ function MyCard({ title, children }) {
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
</Card>;
|
||||
```
|
||||
|
||||
### ❌ Mistake 5: Not using cn() utility
|
||||
@@ -497,7 +503,7 @@ Add to `.github/copilot-instructions.md`:
|
||||
```markdown
|
||||
# Component Guidelines
|
||||
|
||||
- Import from @/components/ui/*
|
||||
- Import from @/components/ui/\*
|
||||
- Use semantic colors: bg-primary, text-destructive
|
||||
- Spacing: multiples of 4 (p-4, mb-6, gap-8)
|
||||
- Use cn() for className merging
|
||||
@@ -525,20 +531,20 @@ interface DashboardCardProps {
|
||||
|
||||
export function DashboardCard({ title, value, trend, className }: DashboardCardProps) {
|
||||
return (
|
||||
<Card className={cn("p-6", className)}>
|
||||
<Card className={cn('p-6', className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{trend && (
|
||||
<p className={cn(
|
||||
"text-xs",
|
||||
trend === 'up' && "text-green-600",
|
||||
trend === 'down' && "text-destructive"
|
||||
)}>
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs',
|
||||
trend === 'up' && 'text-green-600',
|
||||
trend === 'down' && 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{trend === 'up' ? '↑' : '↓'} Trend
|
||||
</p>
|
||||
)}
|
||||
@@ -549,6 +555,7 @@ export function DashboardCard({ title, value, trend, className }: DashboardCardP
|
||||
```
|
||||
|
||||
**Why it's good:**
|
||||
|
||||
- ✅ Imports from `@/components/ui/*`
|
||||
- ✅ Uses semantic tokens
|
||||
- ✅ Uses `cn()` utility
|
||||
|
||||
@@ -20,21 +20,21 @@
|
||||
|
||||
### Semantic Colors
|
||||
|
||||
| Token | Usage | Example |
|
||||
|-------|-------|---------|
|
||||
| `bg-primary text-primary-foreground` | CTAs, primary actions | Primary button |
|
||||
| `bg-secondary text-secondary-foreground` | Secondary actions | Secondary button |
|
||||
| `bg-destructive text-destructive-foreground` | Delete, errors | Delete button, error alert |
|
||||
| `bg-muted text-muted-foreground` | Disabled, subtle | Disabled button, TabsList |
|
||||
| `bg-accent text-accent-foreground` | Hover states | Dropdown hover |
|
||||
| `bg-card text-card-foreground` | Cards, elevated surfaces | Card component |
|
||||
| `bg-popover text-popover-foreground` | Popovers, dropdowns | Dropdown content |
|
||||
| `bg-background text-foreground` | Page background | Body |
|
||||
| `text-foreground` | Body text | Paragraphs |
|
||||
| `text-muted-foreground` | Secondary text | Captions, helper text |
|
||||
| `border-border` | Borders, dividers | Card borders, separators |
|
||||
| `border-input` | Input borders | Text input border |
|
||||
| `ring-ring` | Focus indicators | Focus ring |
|
||||
| Token | Usage | Example |
|
||||
| -------------------------------------------- | ------------------------ | -------------------------- |
|
||||
| `bg-primary text-primary-foreground` | CTAs, primary actions | Primary button |
|
||||
| `bg-secondary text-secondary-foreground` | Secondary actions | Secondary button |
|
||||
| `bg-destructive text-destructive-foreground` | Delete, errors | Delete button, error alert |
|
||||
| `bg-muted text-muted-foreground` | Disabled, subtle | Disabled button, TabsList |
|
||||
| `bg-accent text-accent-foreground` | Hover states | Dropdown hover |
|
||||
| `bg-card text-card-foreground` | Cards, elevated surfaces | Card component |
|
||||
| `bg-popover text-popover-foreground` | Popovers, dropdowns | Dropdown content |
|
||||
| `bg-background text-foreground` | Page background | Body |
|
||||
| `text-foreground` | Body text | Paragraphs |
|
||||
| `text-muted-foreground` | Secondary text | Captions, helper text |
|
||||
| `border-border` | Borders, dividers | Card borders, separators |
|
||||
| `border-input` | Input borders | Text input border |
|
||||
| `ring-ring` | Focus indicators | Focus ring |
|
||||
|
||||
### Usage Examples
|
||||
|
||||
@@ -61,29 +61,29 @@
|
||||
|
||||
### Font Sizes
|
||||
|
||||
| Class | rem | px | Use Case | Common |
|
||||
|-------|-----|----|----|:------:|
|
||||
| `text-xs` | 0.75rem | 12px | Labels, fine print | |
|
||||
| `text-sm` | 0.875rem | 14px | Secondary text, captions | ⭐ |
|
||||
| `text-base` | 1rem | 16px | Body text (default) | ⭐ |
|
||||
| `text-lg` | 1.125rem | 18px | Subheadings | |
|
||||
| `text-xl` | 1.25rem | 20px | Card titles | ⭐ |
|
||||
| `text-2xl` | 1.5rem | 24px | Section headings | ⭐ |
|
||||
| `text-3xl` | 1.875rem | 30px | Page titles | ⭐ |
|
||||
| `text-4xl` | 2.25rem | 36px | Large headings | |
|
||||
| `text-5xl` | 3rem | 48px | Hero text | |
|
||||
| Class | rem | px | Use Case | Common |
|
||||
| ----------- | -------- | ---- | ------------------------ | :----: |
|
||||
| `text-xs` | 0.75rem | 12px | Labels, fine print | |
|
||||
| `text-sm` | 0.875rem | 14px | Secondary text, captions | ⭐ |
|
||||
| `text-base` | 1rem | 16px | Body text (default) | ⭐ |
|
||||
| `text-lg` | 1.125rem | 18px | Subheadings | |
|
||||
| `text-xl` | 1.25rem | 20px | Card titles | ⭐ |
|
||||
| `text-2xl` | 1.5rem | 24px | Section headings | ⭐ |
|
||||
| `text-3xl` | 1.875rem | 30px | Page titles | ⭐ |
|
||||
| `text-4xl` | 2.25rem | 36px | Large headings | |
|
||||
| `text-5xl` | 3rem | 48px | Hero text | |
|
||||
|
||||
⭐ = Most commonly used
|
||||
|
||||
### Font Weights
|
||||
|
||||
| Class | Value | Use Case | Common |
|
||||
|-------|-------|----------|:------:|
|
||||
| `font-light` | 300 | De-emphasized text | |
|
||||
| `font-normal` | 400 | Body text (default) | ⭐ |
|
||||
| `font-medium` | 500 | Labels, menu items | ⭐ |
|
||||
| `font-semibold` | 600 | Subheadings, buttons | ⭐ |
|
||||
| `font-bold` | 700 | Headings, emphasis | ⭐ |
|
||||
| Class | Value | Use Case | Common |
|
||||
| --------------- | ----- | -------------------- | :----: |
|
||||
| `font-light` | 300 | De-emphasized text | |
|
||||
| `font-normal` | 400 | Body text (default) | ⭐ |
|
||||
| `font-medium` | 500 | Labels, menu items | ⭐ |
|
||||
| `font-semibold` | 600 | Subheadings, buttons | ⭐ |
|
||||
| `font-bold` | 700 | Headings, emphasis | ⭐ |
|
||||
|
||||
⭐ = Most commonly used
|
||||
|
||||
@@ -115,35 +115,35 @@
|
||||
|
||||
### Spacing Values
|
||||
|
||||
| Token | rem | px | Use Case | Common |
|
||||
|-------|-----|----|----|:------:|
|
||||
| `0` | 0 | 0px | No spacing | |
|
||||
| `px` | - | 1px | Borders | |
|
||||
| `0.5` | 0.125rem | 2px | Very tight | |
|
||||
| `1` | 0.25rem | 4px | Icon gaps | |
|
||||
| `2` | 0.5rem | 8px | Tight spacing (label → input) | ⭐ |
|
||||
| `3` | 0.75rem | 12px | Component padding | |
|
||||
| `4` | 1rem | 16px | Standard spacing (form fields) | ⭐ |
|
||||
| `5` | 1.25rem | 20px | Medium spacing | |
|
||||
| `6` | 1.5rem | 24px | Section spacing (cards) | ⭐ |
|
||||
| `8` | 2rem | 32px | Large gaps | ⭐ |
|
||||
| `10` | 2.5rem | 40px | Very large gaps | |
|
||||
| `12` | 3rem | 48px | Section dividers | ⭐ |
|
||||
| `16` | 4rem | 64px | Page sections | |
|
||||
| Token | rem | px | Use Case | Common |
|
||||
| ----- | -------- | ---- | ------------------------------ | :----: |
|
||||
| `0` | 0 | 0px | No spacing | |
|
||||
| `px` | - | 1px | Borders | |
|
||||
| `0.5` | 0.125rem | 2px | Very tight | |
|
||||
| `1` | 0.25rem | 4px | Icon gaps | |
|
||||
| `2` | 0.5rem | 8px | Tight spacing (label → input) | ⭐ |
|
||||
| `3` | 0.75rem | 12px | Component padding | |
|
||||
| `4` | 1rem | 16px | Standard spacing (form fields) | ⭐ |
|
||||
| `5` | 1.25rem | 20px | Medium spacing | |
|
||||
| `6` | 1.5rem | 24px | Section spacing (cards) | ⭐ |
|
||||
| `8` | 2rem | 32px | Large gaps | ⭐ |
|
||||
| `10` | 2.5rem | 40px | Very large gaps | |
|
||||
| `12` | 3rem | 48px | Section dividers | ⭐ |
|
||||
| `16` | 4rem | 64px | Page sections | |
|
||||
|
||||
⭐ = Most commonly used
|
||||
|
||||
### Spacing Methods
|
||||
|
||||
| Method | Use Case | Example |
|
||||
|--------|----------|---------|
|
||||
| `gap-4` | Flex/grid spacing | `flex gap-4` |
|
||||
| `space-y-4` | Vertical stack spacing | `space-y-4` |
|
||||
| `space-x-4` | Horizontal stack spacing | `space-x-4` |
|
||||
| `p-4` | Padding (all sides) | `p-4` |
|
||||
| `px-4` | Horizontal padding | `px-4` |
|
||||
| `py-4` | Vertical padding | `py-4` |
|
||||
| `m-4` | Margin (exceptions only!) | `mt-8` |
|
||||
| Method | Use Case | Example |
|
||||
| ----------- | ------------------------- | ------------ |
|
||||
| `gap-4` | Flex/grid spacing | `flex gap-4` |
|
||||
| `space-y-4` | Vertical stack spacing | `space-y-4` |
|
||||
| `space-x-4` | Horizontal stack spacing | `space-x-4` |
|
||||
| `p-4` | Padding (all sides) | `p-4` |
|
||||
| `px-4` | Horizontal padding | `px-4` |
|
||||
| `py-4` | Vertical padding | `py-4` |
|
||||
| `m-4` | Margin (exceptions only!) | `mt-8` |
|
||||
|
||||
### Common Spacing Patterns
|
||||
|
||||
@@ -217,52 +217,52 @@
|
||||
|
||||
```tsx
|
||||
// 1 → 2 → 3 progression (most common)
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
className = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6';
|
||||
|
||||
// 1 → 2 → 4 progression
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"
|
||||
className = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6';
|
||||
|
||||
// 1 → 2 progression (simple)
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-6"
|
||||
className = 'grid grid-cols-1 md:grid-cols-2 gap-6';
|
||||
|
||||
// 1 → 3 progression (skip 2)
|
||||
className="grid grid-cols-1 lg:grid-cols-3 gap-6"
|
||||
className = 'grid grid-cols-1 lg:grid-cols-3 gap-6';
|
||||
```
|
||||
|
||||
### Container Widths
|
||||
|
||||
```tsx
|
||||
// Standard container
|
||||
className="container mx-auto px-4"
|
||||
className = 'container mx-auto px-4';
|
||||
|
||||
// Constrained widths
|
||||
className="max-w-md mx-auto" // 448px - Forms
|
||||
className="max-w-lg mx-auto" // 512px - Modals
|
||||
className="max-w-2xl mx-auto" // 672px - Articles
|
||||
className="max-w-4xl mx-auto" // 896px - Wide layouts
|
||||
className="max-w-7xl mx-auto" // 1280px - Full page
|
||||
className = 'max-w-md mx-auto'; // 448px - Forms
|
||||
className = 'max-w-lg mx-auto'; // 512px - Modals
|
||||
className = 'max-w-2xl mx-auto'; // 672px - Articles
|
||||
className = 'max-w-4xl mx-auto'; // 896px - Wide layouts
|
||||
className = 'max-w-7xl mx-auto'; // 1280px - Full page
|
||||
```
|
||||
|
||||
### Flex Patterns
|
||||
|
||||
```tsx
|
||||
// Horizontal flex
|
||||
className="flex gap-4"
|
||||
className = 'flex gap-4';
|
||||
|
||||
// Vertical flex
|
||||
className="flex flex-col gap-4"
|
||||
className = 'flex flex-col gap-4';
|
||||
|
||||
// Center items
|
||||
className="flex items-center justify-center"
|
||||
className = 'flex items-center justify-center';
|
||||
|
||||
// Space between
|
||||
className="flex items-center justify-between"
|
||||
className = 'flex items-center justify-between';
|
||||
|
||||
// Wrap items
|
||||
className="flex flex-wrap gap-4"
|
||||
className = 'flex flex-wrap gap-4';
|
||||
|
||||
// Responsive: stack on mobile, row on desktop
|
||||
className="flex flex-col sm:flex-row gap-4"
|
||||
className = 'flex flex-col sm:flex-row gap-4';
|
||||
```
|
||||
|
||||
---
|
||||
@@ -273,9 +273,7 @@ className="flex flex-col sm:flex-row gap-4"
|
||||
|
||||
```tsx
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Content */}
|
||||
</div>
|
||||
<div className="max-w-4xl mx-auto space-y-6">{/* Content */}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
@@ -287,7 +285,9 @@ className="flex flex-col sm:flex-row gap-4"
|
||||
<CardTitle>Title</CardTitle>
|
||||
<CardDescription>Description</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">Action</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
Action
|
||||
</Button>
|
||||
</CardHeader>
|
||||
```
|
||||
|
||||
@@ -319,9 +319,7 @@ className="flex flex-col sm:flex-row gap-4"
|
||||
<CardTitle>Form Title</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4">
|
||||
{/* Fields */}
|
||||
</form>
|
||||
<form className="space-y-4">{/* Fields */}</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -354,17 +352,13 @@ className="flex flex-col sm:flex-row gap-4"
|
||||
### Responsive Text
|
||||
|
||||
```tsx
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold">
|
||||
Responsive Title
|
||||
</h1>
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold">Responsive Title</h1>
|
||||
```
|
||||
|
||||
### Responsive Padding
|
||||
|
||||
```tsx
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
Responsive padding
|
||||
</div>
|
||||
<div className="p-4 sm:p-6 lg:p-8">Responsive padding</div>
|
||||
```
|
||||
|
||||
---
|
||||
@@ -427,16 +421,16 @@ Has error?
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action | Context |
|
||||
|-----|--------|---------|
|
||||
| `Tab` | Move focus forward | All |
|
||||
| `Shift + Tab` | Move focus backward | All |
|
||||
| `Enter` | Activate button/link | Buttons, links |
|
||||
| `Space` | Activate button/checkbox | Buttons, checkboxes |
|
||||
| `Escape` | Close overlay | Dialogs, dropdowns |
|
||||
| `Arrow keys` | Navigate items | Dropdowns, lists |
|
||||
| `Home` | Jump to start | Lists |
|
||||
| `End` | Jump to end | Lists |
|
||||
| Key | Action | Context |
|
||||
| ------------- | ------------------------ | ------------------- |
|
||||
| `Tab` | Move focus forward | All |
|
||||
| `Shift + Tab` | Move focus backward | All |
|
||||
| `Enter` | Activate button/link | Buttons, links |
|
||||
| `Space` | Activate button/checkbox | Buttons, checkboxes |
|
||||
| `Escape` | Close overlay | Dialogs, dropdowns |
|
||||
| `Arrow keys` | Navigate items | Dropdowns, lists |
|
||||
| `Home` | Jump to start | Lists |
|
||||
| `End` | Jump to end | Lists |
|
||||
|
||||
---
|
||||
|
||||
@@ -482,7 +476,13 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
|
||||
// Utilities
|
||||
@@ -506,53 +506,53 @@ import { Check, X, AlertCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
```tsx
|
||||
// Required string
|
||||
z.string().min(1, 'Required')
|
||||
z.string().min(1, 'Required');
|
||||
|
||||
// Email
|
||||
z.string().email('Invalid email')
|
||||
z.string().email('Invalid email');
|
||||
|
||||
// Min/max length
|
||||
z.string().min(8, 'Min 8 chars').max(100, 'Max 100 chars')
|
||||
z.string().min(8, 'Min 8 chars').max(100, 'Max 100 chars');
|
||||
|
||||
// Optional
|
||||
z.string().optional()
|
||||
z.string().optional();
|
||||
|
||||
// Number
|
||||
z.coerce.number().min(0).max(100)
|
||||
z.coerce.number().min(0).max(100);
|
||||
|
||||
// Enum
|
||||
z.enum(['admin', 'user', 'guest'])
|
||||
z.enum(['admin', 'user', 'guest']);
|
||||
|
||||
// Boolean
|
||||
z.boolean().refine(val => val === true, { message: 'Must accept' })
|
||||
z.boolean().refine((val) => val === true, { message: 'Must accept' });
|
||||
|
||||
// Password confirmation
|
||||
z.object({
|
||||
password: z.string().min(8),
|
||||
confirmPassword: z.string()
|
||||
}).refine(data => data.password === data.confirmPassword, {
|
||||
confirmPassword: z.string(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
| Breakpoint | Min Width | Typical Device |
|
||||
|------------|-----------|----------------|
|
||||
| `sm:` | 640px | Large phones, small tablets |
|
||||
| `md:` | 768px | Tablets |
|
||||
| `lg:` | 1024px | Laptops, desktops |
|
||||
| `xl:` | 1280px | Large desktops |
|
||||
| `2xl:` | 1536px | Extra large screens |
|
||||
| Breakpoint | Min Width | Typical Device |
|
||||
| ---------- | --------- | --------------------------- |
|
||||
| `sm:` | 640px | Large phones, small tablets |
|
||||
| `md:` | 768px | Tablets |
|
||||
| `lg:` | 1024px | Laptops, desktops |
|
||||
| `xl:` | 1280px | Large desktops |
|
||||
| `2xl:` | 1536px | Extra large screens |
|
||||
|
||||
```tsx
|
||||
// Mobile-first (default → sm → md → lg)
|
||||
className="text-sm sm:text-base md:text-lg lg:text-xl"
|
||||
className="p-4 sm:p-6 lg:p-8"
|
||||
className="grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
|
||||
className = 'text-sm sm:text-base md:text-lg lg:text-xl';
|
||||
className = 'p-4 sm:p-6 lg:p-8';
|
||||
className = 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3';
|
||||
```
|
||||
|
||||
---
|
||||
@@ -562,20 +562,20 @@ className="grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
|
||||
### Shadows
|
||||
|
||||
```tsx
|
||||
shadow-sm // Cards, panels
|
||||
shadow-md // Dropdowns, tooltips
|
||||
shadow-lg // Modals, popovers
|
||||
shadow-xl // Floating notifications
|
||||
shadow - sm; // Cards, panels
|
||||
shadow - md; // Dropdowns, tooltips
|
||||
shadow - lg; // Modals, popovers
|
||||
shadow - xl; // Floating notifications
|
||||
```
|
||||
|
||||
### Border Radius
|
||||
|
||||
```tsx
|
||||
rounded-sm // 2px - Tags, small badges
|
||||
rounded-md // 4px - Inputs, small buttons
|
||||
rounded-lg // 6px - Cards, buttons (default)
|
||||
rounded-xl // 10px - Large cards, modals
|
||||
rounded-full // Pills, avatars, icon buttons
|
||||
rounded - sm; // 2px - Tags, small badges
|
||||
rounded - md; // 4px - Inputs, small buttons
|
||||
rounded - lg; // 6px - Cards, buttons (default)
|
||||
rounded - xl; // 10px - Large cards, modals
|
||||
rounded - full; // Pills, avatars, icon buttons
|
||||
```
|
||||
|
||||
---
|
||||
@@ -589,6 +589,7 @@ rounded-full // Pills, avatars, icon buttons
|
||||
---
|
||||
|
||||
**Related Documentation:**
|
||||
|
||||
- [Quick Start](./00-quick-start.md) - 5-minute crash course
|
||||
- [Foundations](./01-foundations.md) - Detailed color, typography, spacing
|
||||
- [Components](./02-components.md) - All component variants
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
## Project Overview
|
||||
|
||||
### Vision
|
||||
|
||||
Create a world-class design system documentation that:
|
||||
|
||||
- Follows Pareto principle (80% coverage with 20% content)
|
||||
- Includes AI-specific code generation guidelines
|
||||
- Provides interactive, copy-paste examples
|
||||
@@ -18,6 +20,7 @@ Create a world-class design system documentation that:
|
||||
- Maintains perfect internal coherence and link integrity
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Pareto-Efficiency** - 80/20 rule applied throughout
|
||||
2. **AI-Optimized** - Dedicated guidelines for AI code generation
|
||||
3. **Interconnected** - All docs cross-reference each other
|
||||
@@ -163,6 +166,7 @@ Create a world-class design system documentation that:
|
||||
### Documentation Review & Fixes ✅
|
||||
|
||||
#### Issues Found During Review:
|
||||
|
||||
1. **Time estimates in section headers** - Removed all (user request)
|
||||
- Removed "⏱️ Time to productive: 5 minutes" from header
|
||||
- Removed "(3 minutes)", "(30 seconds)" from all section headers
|
||||
@@ -178,6 +182,7 @@ Create a world-class design system documentation that:
|
||||
- Fixed: Added missing `SelectGroup` and `SelectLabel` to import statement in 02-components.md
|
||||
|
||||
#### Comprehensive Review Results:
|
||||
|
||||
- **✅ 100+ links checked**
|
||||
- **✅ 0 broken internal doc links**
|
||||
- **✅ 0 logic inconsistencies**
|
||||
@@ -200,7 +205,9 @@ Create a world-class design system documentation that:
|
||||
## Phase 2: Interactive Demos (PENDING)
|
||||
|
||||
### Objective
|
||||
|
||||
Create live, interactive demonstration pages at `/dev/*` routes with:
|
||||
|
||||
- Copy-paste ready code snippets
|
||||
- Before/after comparisons
|
||||
- Live component examples
|
||||
@@ -272,12 +279,14 @@ Create live, interactive demonstration pages at `/dev/*` routes with:
|
||||
### Overall Progress: 100% Complete ✅
|
||||
|
||||
**Phase 1: Documentation** ✅ 100% (14/14 tasks)
|
||||
|
||||
- All documentation files created (~7,600 lines)
|
||||
- All issues fixed (4 issues resolved)
|
||||
- Comprehensive review completed (100+ links verified)
|
||||
- CLAUDE.md updated
|
||||
|
||||
**Phase 2: Interactive Demos** ✅ 100% (6/6 tasks)
|
||||
|
||||
- Utility components created (~470 lines)
|
||||
- Hub page created (~220 lines)
|
||||
- All demo pages created and enhanced (~2,388 lines)
|
||||
@@ -386,6 +395,7 @@ Create live, interactive demonstration pages at `/dev/*` routes with:
|
||||
### Technical Implementation
|
||||
|
||||
**Technologies Used:**
|
||||
|
||||
- Next.js 15 App Router
|
||||
- React 19 + TypeScript
|
||||
- shadcn/ui components (all)
|
||||
@@ -395,6 +405,7 @@ Create live, interactive demonstration pages at `/dev/*` routes with:
|
||||
- Responsive design (mobile-first)
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- Server components for static pages (hub, layouts, spacing)
|
||||
- Client components for interactive pages (components, forms)
|
||||
- Reusable utility components in `/src/components/dev/`
|
||||
|
||||
@@ -6,26 +6,28 @@
|
||||
|
||||
## 🚀 Quick Navigation
|
||||
|
||||
| For... | Start Here | Time |
|
||||
|--------|-----------|------|
|
||||
| **Quick Start** | [⚡ 5-Minute Crash Course](./00-quick-start.md) | 5 min |
|
||||
| **Component Development** | [🧩 Components](./02-components.md) → [🔨 Creation Guide](./05-component-creation.md) | 15 min |
|
||||
| **Layout Design** | [📐 Layouts](./03-layouts.md) → [📏 Spacing](./04-spacing-philosophy.md) | 20 min |
|
||||
| **AI Code Generation** | [🤖 AI Guidelines](./08-ai-guidelines.md) | 3 min |
|
||||
| **Quick Reference** | [📚 Reference Tables](./99-reference.md) | Instant |
|
||||
| **Complete Guide** | Read all docs in order | 1 hour |
|
||||
| For... | Start Here | Time |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------- | ------- |
|
||||
| **Quick Start** | [⚡ 5-Minute Crash Course](./00-quick-start.md) | 5 min |
|
||||
| **Component Development** | [🧩 Components](./02-components.md) → [🔨 Creation Guide](./05-component-creation.md) | 15 min |
|
||||
| **Layout Design** | [📐 Layouts](./03-layouts.md) → [📏 Spacing](./04-spacing-philosophy.md) | 20 min |
|
||||
| **AI Code Generation** | [🤖 AI Guidelines](./08-ai-guidelines.md) | 3 min |
|
||||
| **Quick Reference** | [📚 Reference Tables](./99-reference.md) | Instant |
|
||||
| **Complete Guide** | Read all docs in order | 1 hour |
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation Structure
|
||||
|
||||
### Getting Started
|
||||
|
||||
- **[00. Quick Start](./00-quick-start.md)** ⚡
|
||||
- 5-minute crash course
|
||||
- Essential components and patterns
|
||||
- Copy-paste ready examples
|
||||
|
||||
### Fundamentals
|
||||
|
||||
- **[01. Foundations](./01-foundations.md)** 🎨
|
||||
- Color system (OKLCH)
|
||||
- Typography scale
|
||||
@@ -39,6 +41,7 @@
|
||||
- Composition patterns
|
||||
|
||||
### Layouts & Spacing
|
||||
|
||||
- **[03. Layouts](./03-layouts.md)** 📐
|
||||
- Grid vs Flex decision tree
|
||||
- Common layout patterns
|
||||
@@ -52,6 +55,7 @@
|
||||
- Consistency patterns
|
||||
|
||||
### Building Components
|
||||
|
||||
- **[05. Component Creation](./05-component-creation.md)** 🔨
|
||||
- When to create vs compose
|
||||
- Component templates
|
||||
@@ -65,6 +69,7 @@
|
||||
- Multi-field examples
|
||||
|
||||
### Best Practices
|
||||
|
||||
- **[07. Accessibility](./07-accessibility.md)** ♿
|
||||
- WCAG AA compliance
|
||||
- Keyboard navigation
|
||||
@@ -78,6 +83,7 @@
|
||||
- Component templates
|
||||
|
||||
### Reference
|
||||
|
||||
- **[99. Reference Tables](./99-reference.md)** 📚
|
||||
- Quick lookup tables
|
||||
- All tokens at a glance
|
||||
@@ -95,6 +101,7 @@ Explore live examples and copy-paste code:
|
||||
- **[Form Patterns](/dev/forms)** - Complete form examples
|
||||
|
||||
Each demo page includes:
|
||||
|
||||
- ✅ Live, interactive examples
|
||||
- ✅ Click-to-copy code snippets
|
||||
- ✅ Before/after comparisons
|
||||
@@ -105,6 +112,7 @@ Each demo page includes:
|
||||
## 🛤️ Learning Paths
|
||||
|
||||
### Path 1: Speedrun (5 minutes)
|
||||
|
||||
**Goal**: Start building immediately
|
||||
|
||||
1. [Quick Start](./00-quick-start.md) - Essential patterns
|
||||
@@ -116,6 +124,7 @@ Each demo page includes:
|
||||
---
|
||||
|
||||
### Path 2: Component Developer (15 minutes)
|
||||
|
||||
**Goal**: Master component building
|
||||
|
||||
1. [Quick Start](./00-quick-start.md) - Basics
|
||||
@@ -128,6 +137,7 @@ Each demo page includes:
|
||||
---
|
||||
|
||||
### Path 3: Layout Specialist (20 minutes)
|
||||
|
||||
**Goal**: Master layouts and spacing
|
||||
|
||||
1. [Quick Start](./00-quick-start.md) - Basics
|
||||
@@ -141,6 +151,7 @@ Each demo page includes:
|
||||
---
|
||||
|
||||
### Path 4: Form Specialist (15 minutes)
|
||||
|
||||
**Goal**: Master forms and validation
|
||||
|
||||
1. [Quick Start](./00-quick-start.md) - Basics
|
||||
@@ -154,6 +165,7 @@ Each demo page includes:
|
||||
---
|
||||
|
||||
### Path 5: AI Setup (3 minutes)
|
||||
|
||||
**Goal**: Configure AI for perfect code generation
|
||||
|
||||
1. [AI Guidelines](./08-ai-guidelines.md) - Read once, code forever
|
||||
@@ -164,9 +176,11 @@ Each demo page includes:
|
||||
---
|
||||
|
||||
### Path 6: Comprehensive Mastery (1 hour)
|
||||
|
||||
**Goal**: Complete understanding of the design system
|
||||
|
||||
Read all documents in order:
|
||||
|
||||
1. [Quick Start](./00-quick-start.md)
|
||||
2. [Foundations](./01-foundations.md)
|
||||
3. [Components](./02-components.md)
|
||||
@@ -211,18 +225,21 @@ Our design system is built on these core principles:
|
||||
## 🤝 Contributing to the Design System
|
||||
|
||||
### Adding a New Component
|
||||
|
||||
1. Read [Component Creation Guide](./05-component-creation.md)
|
||||
2. Follow the template
|
||||
3. Add to [Component Showcase](/dev/components)
|
||||
4. Document in [Components](./02-components.md)
|
||||
|
||||
### Adding a New Pattern
|
||||
|
||||
1. Validate it solves a real need (used 3+ times)
|
||||
2. Document in appropriate guide
|
||||
3. Add to [Reference](./99-reference.md)
|
||||
4. Create example in `/dev/`
|
||||
|
||||
### Updating Colors/Tokens
|
||||
|
||||
1. Edit `src/app/globals.css`
|
||||
2. Test in both light and dark modes
|
||||
3. Verify WCAG AA contrast
|
||||
|
||||
Reference in New Issue
Block a user