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:
2025-11-10 11:03:45 +01:00
parent 464a6140c4
commit 96df7edf88
208 changed files with 4056 additions and 4556 deletions

View File

@@ -1,8 +1,6 @@
{ {
"all": true, "all": true,
"include": [ "include": ["src/**/*.{js,jsx,ts,tsx}"],
"src/**/*.{js,jsx,ts,tsx}"
],
"exclude": [ "exclude": [
"src/**/*.d.ts", "src/**/*.d.ts",
"src/**/*.test.{js,jsx,ts,tsx}", "src/**/*.test.{js,jsx,ts,tsx}",
@@ -16,13 +14,7 @@
"src/lib/utils/cn.ts", "src/lib/utils/cn.ts",
"src/middleware.ts" "src/middleware.ts"
], ],
"reporter": [ "reporter": ["text", "text-summary", "html", "json", "lcov"],
"text",
"text-summary",
"html",
"json",
"lcov"
],
"report-dir": "./coverage-combined", "report-dir": "./coverage-combined",
"temp-dir": "./.nyc_output", "temp-dir": "./.nyc_output",
"sourceMap": true, "sourceMap": true,

File diff suppressed because it is too large Load Diff

View File

@@ -110,6 +110,7 @@ src/lib/api/generated/
### 2.3 When to Regenerate ### 2.3 When to Regenerate
Regenerate the API client when: Regenerate the API client when:
- Backend API changes (new endpoints, updated models) - Backend API changes (new endpoints, updated models)
- After pulling backend changes from git - After pulling backend changes from git
- When types don't match backend responses - When types don't match backend responses
@@ -122,6 +123,7 @@ Regenerate the API client when:
### 3.1 Using Generated Services ### 3.1 Using Generated Services
**Example: Fetching users** **Example: Fetching users**
```typescript ```typescript
import { UsersService } from '@/lib/api/generated'; import { UsersService } from '@/lib/api/generated';
@@ -129,19 +131,20 @@ async function getUsers() {
const users = await UsersService.getUsers({ const users = await UsersService.getUsers({
page: 1, page: 1,
pageSize: 20, pageSize: 20,
search: 'john' search: 'john',
}); });
return users; return users;
} }
``` ```
**Example: Creating a user** **Example: Creating a user**
```typescript ```typescript
import { AdminService } from '@/lib/api/generated'; import { AdminService } from '@/lib/api/generated';
async function createUser(data: CreateUserDto) { async function createUser(data: CreateUserDto) {
const newUser = await AdminService.createUser({ const newUser = await AdminService.createUser({
requestBody: data requestBody: data,
}); });
return newUser; return newUser;
} }
@@ -156,19 +159,19 @@ import { apiClient } from '@/lib/api/client';
// GET request // GET request
const response = await apiClient.get<User[]>('/users', { const response = await apiClient.get<User[]>('/users', {
params: { page: 1, search: 'john' } params: { page: 1, search: 'john' },
}); });
// POST request // POST request
const response = await apiClient.post<User>('/admin/users', { const response = await apiClient.post<User>('/admin/users', {
email: 'user@example.com', email: 'user@example.com',
first_name: 'John', first_name: 'John',
password: 'secure123' password: 'secure123',
}); });
// PATCH request // PATCH request
const response = await apiClient.patch<User>(`/users/${userId}`, { const response = await apiClient.patch<User>(`/users/${userId}`, {
first_name: 'Jane' first_name: 'Jane',
}); });
// DELETE request // DELETE request
@@ -178,27 +181,30 @@ await apiClient.delete(`/users/${userId}`);
### 3.3 Request Configuration ### 3.3 Request Configuration
**Timeouts:** **Timeouts:**
```typescript ```typescript
const response = await apiClient.get('/users', { const response = await apiClient.get('/users', {
timeout: 5000 // 5 seconds timeout: 5000, // 5 seconds
}); });
``` ```
**Custom Headers:** **Custom Headers:**
```typescript ```typescript
const response = await apiClient.post('/users', data, { const response = await apiClient.post('/users', data, {
headers: { headers: {
'X-Custom-Header': 'value' 'X-Custom-Header': 'value',
} },
}); });
``` ```
**Request Cancellation:** **Request Cancellation:**
```typescript ```typescript
const controller = new AbortController(); const controller = new AbortController();
const response = await apiClient.get('/users', { const response = await apiClient.get('/users', {
signal: controller.signal signal: controller.signal,
}); });
// Cancel the request // Cancel the request
@@ -215,15 +221,13 @@ The Axios client automatically adds the Authorization header to all requests:
```typescript ```typescript
// src/lib/api/client.ts // src/lib/api/client.ts
apiClient.interceptors.request.use( apiClient.interceptors.request.use((config) => {
(config) => { const token = useAuthStore.getState().accessToken;
const token = useAuthStore.getState().accessToken; if (token) {
if (token) { config.headers.Authorization = `Bearer ${token}`;
config.headers.Authorization = `Bearer ${token}`;
}
return config;
} }
); return config;
});
``` ```
You don't need to manually add auth headers - they're added automatically! You don't need to manually add auth headers - they're added automatically!
@@ -246,7 +250,7 @@ apiClient.interceptors.response.use(
// Refresh tokens // Refresh tokens
const refreshToken = getRefreshToken(); const refreshToken = getRefreshToken();
const { access_token, refresh_token } = await AuthService.refreshToken({ const { access_token, refresh_token } = await AuthService.refreshToken({
requestBody: { refresh_token: refreshToken } requestBody: { refresh_token: refreshToken },
}); });
// Update stored tokens // Update stored tokens
@@ -255,7 +259,6 @@ apiClient.interceptors.response.use(
// Retry original request with new token // Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${access_token}`; originalRequest.headers.Authorization = `Bearer ${access_token}`;
return apiClient.request(originalRequest); return apiClient.request(originalRequest);
} catch (refreshError) { } catch (refreshError) {
// Refresh failed - logout user // Refresh failed - logout user
useAuthStore.getState().clearAuth(); useAuthStore.getState().clearAuth();
@@ -278,14 +281,11 @@ import { useAuthStore } from '@/stores/authStore';
async function login(email: string, password: string) { async function login(email: string, password: string) {
try { try {
const response = await AuthService.login({ const response = await AuthService.login({
requestBody: { email, password } requestBody: { email, password },
}); });
// Store tokens and user // Store tokens and user
useAuthStore.getState().setTokens( useAuthStore.getState().setTokens(response.access_token, response.refresh_token);
response.access_token,
response.refresh_token
);
useAuthStore.getState().setUser(response.user); useAuthStore.getState().setUser(response.user);
return response.user; return response.user;
@@ -319,6 +319,7 @@ The backend returns structured errors:
### 5.2 Parsing Errors ### 5.2 Parsing Errors
**Error Parser** (`src/lib/api/errors.ts`): **Error Parser** (`src/lib/api/errors.ts`):
```typescript ```typescript
import type { AxiosError } from 'axios'; import type { AxiosError } from 'axios';
@@ -341,80 +342,93 @@ export function parseAPIError(error: AxiosError<APIErrorResponse>): APIError[] {
// Network errors // Network errors
if (!error.response) { if (!error.response) {
return [{ return [
code: 'NETWORK_ERROR', {
message: 'Network error. Please check your connection.', code: 'NETWORK_ERROR',
}]; message: 'Network error. Please check your connection.',
},
];
} }
// HTTP status errors // HTTP status errors
const status = error.response.status; const status = error.response.status;
if (status === 403) { if (status === 403) {
return [{ return [
code: 'FORBIDDEN', {
message: "You don't have permission to perform this action.", code: 'FORBIDDEN',
}]; message: "You don't have permission to perform this action.",
},
];
} }
if (status === 404) { if (status === 404) {
return [{ return [
code: 'NOT_FOUND', {
message: 'The requested resource was not found.', code: 'NOT_FOUND',
}]; message: 'The requested resource was not found.',
},
];
} }
if (status === 429) { if (status === 429) {
return [{ return [
code: 'RATE_LIMIT', {
message: 'Too many requests. Please slow down.', code: 'RATE_LIMIT',
}]; message: 'Too many requests. Please slow down.',
},
];
} }
if (status >= 500) { if (status >= 500) {
return [{ return [
code: 'SERVER_ERROR', {
message: 'A server error occurred. Please try again later.', code: 'SERVER_ERROR',
}]; message: 'A server error occurred. Please try again later.',
},
];
} }
// Fallback // Fallback
return [{ return [
code: 'UNKNOWN', {
message: error.message || 'An unexpected error occurred.', code: 'UNKNOWN',
}]; message: error.message || 'An unexpected error occurred.',
},
];
} }
``` ```
### 5.3 Error Code Mapping ### 5.3 Error Code Mapping
**Error Messages** (`src/lib/api/errorMessages.ts`): **Error Messages** (`src/lib/api/errorMessages.ts`):
```typescript ```typescript
export const ERROR_MESSAGES: Record<string, string> = { export const ERROR_MESSAGES: Record<string, string> = {
// Authentication errors (AUTH_xxx) // Authentication errors (AUTH_xxx)
'AUTH_001': 'Invalid email or password', AUTH_001: 'Invalid email or password',
'AUTH_002': 'Account is inactive', AUTH_002: 'Account is inactive',
'AUTH_003': 'Invalid or expired token', AUTH_003: 'Invalid or expired token',
// User errors (USER_xxx) // User errors (USER_xxx)
'USER_001': 'User not found', USER_001: 'User not found',
'USER_002': 'This email is already registered', USER_002: 'This email is already registered',
'USER_003': 'Invalid user data', USER_003: 'Invalid user data',
// Validation errors (VAL_xxx) // Validation errors (VAL_xxx)
'VAL_001': 'Invalid input. Please check your data.', VAL_001: 'Invalid input. Please check your data.',
'VAL_002': 'Email format is invalid', VAL_002: 'Email format is invalid',
'VAL_003': 'Password does not meet requirements', VAL_003: 'Password does not meet requirements',
// Organization errors (ORG_xxx) // Organization errors (ORG_xxx)
'ORG_001': 'Organization name already exists', ORG_001: 'Organization name already exists',
'ORG_002': 'Organization not found', ORG_002: 'Organization not found',
// Permission errors (PERM_xxx) // Permission errors (PERM_xxx)
'PERM_001': 'Insufficient permissions', PERM_001: 'Insufficient permissions',
'PERM_002': 'Admin access required', PERM_002: 'Admin access required',
// Rate limiting (RATE_xxx) // 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 { export function getErrorMessage(code: string): string {
@@ -425,6 +439,7 @@ export function getErrorMessage(code: string): string {
### 5.4 Displaying Errors ### 5.4 Displaying Errors
**In React Query:** **In React Query:**
```typescript ```typescript
import { toast } from 'sonner'; import { toast } from 'sonner';
import { parseAPIError, getErrorMessage } from '@/lib/api/errors'; import { parseAPIError, getErrorMessage } from '@/lib/api/errors';
@@ -442,6 +457,7 @@ export function useUpdateUser() {
``` ```
**In Forms:** **In Forms:**
```typescript ```typescript
const onSubmit = async (data: FormData) => { const onSubmit = async (data: FormData) => {
try { try {
@@ -459,9 +475,9 @@ const onSubmit = async (data: FormData) => {
}); });
// Set general error // Set general error
if (errors.some(err => !err.field)) { if (errors.some((err) => !err.field)) {
form.setError('root', { 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(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (data: CreateUserDto) => mutationFn: (data: CreateUserDto) => AdminService.createUser({ requestBody: data }),
AdminService.createUser({ requestBody: data }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success('User created successfully'); toast.success('User created successfully');
@@ -542,8 +557,7 @@ export function useDeleteUser() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (userId: string) => mutationFn: (userId: string) => AdminService.deleteUser({ userId }),
AdminService.deleteUser({ userId }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success('User deleted successfully'); toast.success('User deleted successfully');
@@ -614,7 +628,7 @@ export function useToggleUserActive() {
mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) => mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) =>
AdminService.updateUser({ AdminService.updateUser({
userId, userId,
requestBody: { is_active: isActive } requestBody: { is_active: isActive },
}), }),
onMutate: async ({ userId, isActive }) => { onMutate: async ({ userId, isActive }) => {
// Cancel outgoing refetches // Cancel outgoing refetches
@@ -687,9 +701,7 @@ export const handlers = [
}), }),
rest.delete('/api/v1/admin/users/:userId', (req, res, ctx) => { rest.delete('/api/v1/admin/users/:userId', (req, res, ctx) => {
return res( return res(ctx.json({ success: true, message: 'User deleted' }));
ctx.json({ success: true, message: 'User deleted' })
);
}), }),
]; ];
``` ```
@@ -844,6 +856,7 @@ function UserDetail({ userId }: { userId: string }) {
**Symptom**: `Access-Control-Allow-Origin` error in console **Symptom**: `Access-Control-Allow-Origin` error in console
**Solution**: Ensure backend CORS is configured for frontend URL: **Solution**: Ensure backend CORS is configured for frontend URL:
```python ```python
# backend/app/main.py # backend/app/main.py
BACKEND_CORS_ORIGINS = ["http://localhost:3000"] BACKEND_CORS_ORIGINS = ["http://localhost:3000"]
@@ -854,12 +867,14 @@ BACKEND_CORS_ORIGINS = ["http://localhost:3000"]
**Symptom**: All API calls return 401 **Symptom**: All API calls return 401
**Possible Causes**: **Possible Causes**:
1. No token in store: Check `useAuthStore.getState().accessToken` 1. No token in store: Check `useAuthStore.getState().accessToken`
2. Token expired: Check token expiration 2. Token expired: Check token expiration
3. Token invalid: Try logging in again 3. Token invalid: Try logging in again
4. Interceptor not working: Check interceptor configuration 4. Interceptor not working: Check interceptor configuration
**Debug**: **Debug**:
```typescript ```typescript
// Log token in interceptor // Log token in interceptor
apiClient.interceptors.request.use((config) => { apiClient.interceptors.request.use((config) => {
@@ -877,6 +892,7 @@ apiClient.interceptors.request.use((config) => {
**Symptom**: TypeScript errors about response types **Symptom**: TypeScript errors about response types
**Solution**: Regenerate API client to sync with backend **Solution**: Regenerate API client to sync with backend
```bash ```bash
npm run generate:api npm run generate:api
``` ```
@@ -886,10 +902,11 @@ npm run generate:api
**Symptom**: UI shows old data after mutation **Symptom**: UI shows old data after mutation
**Solution**: Invalidate queries after mutations **Solution**: Invalidate queries after mutations
```typescript ```typescript
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['users'] });
} };
``` ```
### 9.5 Network Timeout ### 9.5 Network Timeout
@@ -897,6 +914,7 @@ onSuccess: () => {
**Symptom**: Requests timeout **Symptom**: Requests timeout
**Solution**: Increase timeout or check backend performance **Solution**: Increase timeout or check backend performance
```typescript ```typescript
const apiClient = axios.create({ const apiClient = axios.create({
timeout: 60000, // 60 seconds timeout: 60000, // 60 seconds
@@ -908,6 +926,7 @@ const apiClient = axios.create({
## Conclusion ## Conclusion
This guide covers the essential patterns for integrating with the FastAPI backend. For more advanced use cases, refer to: 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) - [TanStack Query Documentation](https://tanstack.com/query/latest)
- [Axios Documentation](https://axios-http.com/) - [Axios Documentation](https://axios-http.com/)
- Backend API documentation at `/docs` endpoint - Backend API documentation at `/docs` endpoint

View File

@@ -88,6 +88,7 @@ This frontend template provides a production-ready foundation for building moder
### 2.1 Core Framework ### 2.1 Core Framework
**Next.js 15.x (App Router)** **Next.js 15.x (App Router)**
- **Why**: Modern React framework with RSC, excellent DX, optimized performance - **Why**: Modern React framework with RSC, excellent DX, optimized performance
- **App Router**: Preferred over Pages Router for better data fetching, layouts, and streaming - **App Router**: Preferred over Pages Router for better data fetching, layouts, and streaming
- **Server Components**: Default for better performance, client components for interactivity - **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 ### 2.2 State Management
**TanStack Query (React Query v5)** **TanStack Query (React Query v5)**
- **Purpose**: Server state management (all API data) - **Purpose**: Server state management (all API data)
- **Why**: Automatic caching, background refetching, request deduplication, optimistic updates - **Why**: Automatic caching, background refetching, request deduplication, optimistic updates
- **Usage**: All data fetching goes through React Query hooks - **Usage**: All data fetching goes through React Query hooks
**Zustand 4.x** **Zustand 4.x**
- **Purpose**: Client-only state (authentication, UI preferences) - **Purpose**: Client-only state (authentication, UI preferences)
- **Why**: Minimal boilerplate, no Context API overhead, simple API - **Why**: Minimal boilerplate, no Context API overhead, simple API
- **Usage**: Auth store, UI store (sidebar, theme, modals) - **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 ### 2.3 UI Layer
**shadcn/ui** **shadcn/ui**
- **Why**: Accessible components (Radix UI), customizable, copy-paste (not npm dependency) - **Why**: Accessible components (Radix UI), customizable, copy-paste (not npm dependency)
- **Components**: Button, Card, Dialog, Form, Input, Table, Toast, etc. - **Components**: Button, Card, Dialog, Form, Input, Table, Toast, etc.
- **Customization**: Tailwind-based, easy to adapt to design system - **Customization**: Tailwind-based, easy to adapt to design system
**Tailwind CSS 4.x** **Tailwind CSS 4.x**
- **Why**: Utility-first, excellent DX, small bundle size, dark mode support - **Why**: Utility-first, excellent DX, small bundle size, dark mode support
- **Strategy**: Class-based dark mode, mobile-first responsive design - **Strategy**: Class-based dark mode, mobile-first responsive design
- **Customization**: Custom theme colors, design tokens - **Customization**: Custom theme colors, design tokens
**Recharts 2.x** **Recharts 2.x**
- **Purpose**: Charts for admin dashboard - **Purpose**: Charts for admin dashboard
- **Why**: React-native, composable, responsive, themed with Tailwind colors - **Why**: React-native, composable, responsive, themed with Tailwind colors
### 2.4 API Layer ### 2.4 API Layer
**@hey-api/openapi-ts** **@hey-api/openapi-ts**
- **Purpose**: Generate TypeScript client from backend OpenAPI spec - **Purpose**: Generate TypeScript client from backend OpenAPI spec
- **Why**: Type-safe API calls, auto-generated types matching backend - **Why**: Type-safe API calls, auto-generated types matching backend
- **Alternative**: Considered `openapi-typescript-codegen` but this is more actively maintained - **Alternative**: Considered `openapi-typescript-codegen` but this is more actively maintained
**Axios 1.x** **Axios 1.x**
- **Purpose**: HTTP client for API calls - **Purpose**: HTTP client for API calls
- **Why**: Interceptor support for auth, better error handling than fetch - **Why**: Interceptor support for auth, better error handling than fetch
- **Usage**: Wrapped in generated API client, configured with auth interceptors - **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 ### 2.5 Forms & Validation
**react-hook-form 7.x** **react-hook-form 7.x**
- **Purpose**: Form state management - **Purpose**: Form state management
- **Why**: Excellent performance, minimal re-renders, great DX - **Why**: Excellent performance, minimal re-renders, great DX
**Zod 3.x** **Zod 3.x**
- **Purpose**: Runtime type validation and schema definition - **Purpose**: Runtime type validation and schema definition
- **Why**: Type inference, composable schemas, integrates with react-hook-form - **Why**: Type inference, composable schemas, integrates with react-hook-form
- **Usage**: All forms use Zod schemas with `zodResolver` - **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 ### 2.6 Testing
**Jest + React Testing Library** **Jest + React Testing Library**
- **Purpose**: Unit and component tests - **Purpose**: Unit and component tests
- **Why**: Industry standard, excellent React support, accessibility-focused - **Why**: Industry standard, excellent React support, accessibility-focused
**Playwright** **Playwright**
- **Purpose**: End-to-end testing - **Purpose**: End-to-end testing
- **Why**: Fast, reliable, multi-browser, great debugging tools - **Why**: Fast, reliable, multi-browser, great debugging tools
- **Coverage Target**: 90%+ for template robustness - **Coverage Target**: 90%+ for template robustness
@@ -208,6 +220,7 @@ Inspired by backend's 5-layer architecture, frontend follows similar separation
``` ```
**Key Rules:** **Key Rules:**
- Pages/Layouts should NOT contain business logic - Pages/Layouts should NOT contain business logic
- Components should NOT call API client directly (use hooks) - Components should NOT call API client directly (use hooks)
- Hooks should NOT contain display logic - 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 ### 3.2 Component Patterns
**Server Components by Default:** **Server Components by Default:**
```typescript ```typescript
// app/(authenticated)/admin/users/page.tsx // app/(authenticated)/admin/users/page.tsx
// Server Component - can fetch data directly // Server Component - can fetch data directly
@@ -232,6 +246,7 @@ export default async function UsersPage() {
``` ```
**Client Components for Interactivity:** **Client Components for Interactivity:**
```typescript ```typescript
// components/admin/UserTable.tsx // components/admin/UserTable.tsx
'use client'; 'use client';
@@ -245,6 +260,7 @@ export function UserTable() {
``` ```
**Composition Over Prop Drilling:** **Composition Over Prop Drilling:**
```typescript ```typescript
// Good: Use composition // Good: Use composition
<Card> <Card>
@@ -358,6 +374,7 @@ Each module has one clear responsibility:
``` ```
**Token Refresh Flow (Automatic):** **Token Refresh Flow (Automatic):**
``` ```
API Request → 401 Response → Check if refresh token exists API Request → 401 Response → Check if refresh token exists
↓ Yes ↓ No ↓ Yes ↓ No
@@ -369,11 +386,13 @@ New Tokens → Update Store → Retry Original Request
### 4.3 State Updates ### 4.3 State Updates
**Server State (React Query):** **Server State (React Query):**
- Automatic background refetch - Automatic background refetch
- Cache invalidation on mutations - Cache invalidation on mutations
- Optimistic updates where appropriate - Optimistic updates where appropriate
**Client State (Zustand):** **Client State (Zustand):**
- Direct store updates - Direct store updates
- No actions/reducers boilerplate - No actions/reducers boilerplate
- Subscriptions for components - Subscriptions for components
@@ -385,6 +404,7 @@ New Tokens → Update Store → Retry Original Request
### 5.1 Philosophy ### 5.1 Philosophy
**Use the Right Tool for the Right Job:** **Use the Right Tool for the Right Job:**
- Server data → TanStack Query - Server data → TanStack Query
- Auth & tokens → Zustand - Auth & tokens → Zustand
- UI state → Zustand (minimal) - UI state → Zustand (minimal)
@@ -392,6 +412,7 @@ New Tokens → Update Store → Retry Original Request
- Component state → useState/useReducer - Component state → useState/useReducer
**Avoid Redundancy:** **Avoid Redundancy:**
- DON'T duplicate server data in Zustand - DON'T duplicate server data in Zustand
- DON'T store API responses in global state - DON'T store API responses in global state
- DO keep state as local as possible - DO keep state as local as possible
@@ -399,34 +420,36 @@ New Tokens → Update Store → Retry Original Request
### 5.2 TanStack Query Configuration ### 5.2 TanStack Query Configuration
**Global Config** (`src/config/queryClient.ts`): **Global Config** (`src/config/queryClient.ts`):
```typescript ```typescript
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
cacheTime: 300000, // 5 minutes cacheTime: 300000, // 5 minutes
retry: 3, // Retry failed requests retry: 3, // Retry failed requests
refetchOnWindowFocus: true, // Refetch on tab focus refetchOnWindowFocus: true, // Refetch on tab focus
refetchOnReconnect: true, // Refetch on network reconnect refetchOnReconnect: true, // Refetch on network reconnect
}, },
mutations: { mutations: {
retry: 1, // Retry mutations once retry: 1, // Retry mutations once
}, },
}, },
}); });
``` ```
**Query Key Structure:** **Query Key Structure:**
```typescript ```typescript
['users'] // List all users ['users'][('users', userId)][('users', { page: 1, search: 'john' })][ // List all users // Single user // Filtered list
['users', userId] // Single user ('organizations', orgId, 'members')
['users', { page: 1, search: 'john' }] // Filtered list ]; // Nested resource
['organizations', orgId, 'members'] // Nested resource
``` ```
### 5.3 Zustand Stores ### 5.3 Zustand Stores
**Auth Store** (`src/stores/authStore.ts`): **Auth Store** (`src/stores/authStore.ts`):
```typescript ```typescript
interface AuthStore { interface AuthStore {
user: User | null; user: User | null;
@@ -443,6 +466,7 @@ interface AuthStore {
``` ```
**UI Store** (`src/stores/uiStore.ts`): **UI Store** (`src/stores/uiStore.ts`):
```typescript ```typescript
interface UIStore { interface UIStore {
sidebarOpen: boolean; sidebarOpen: boolean;
@@ -454,6 +478,7 @@ interface UIStore {
``` ```
**Store Guidelines:** **Store Guidelines:**
- Keep stores small and focused - Keep stores small and focused
- Use selectors for computed values - Use selectors for computed values
- Persist to localStorage where appropriate - Persist to localStorage where appropriate
@@ -480,6 +505,7 @@ Component → useAuth() hook → AuthContext → Zustand Store → Storage Layer
**Why This Pattern?** **Why This Pattern?**
**Benefits:** **Benefits:**
- **Testable**: E2E tests can inject mock stores without backend - **Testable**: E2E tests can inject mock stores without backend
- **Performant**: Zustand handles state efficiently, Context is just a thin wrapper - **Performant**: Zustand handles state efficiently, Context is just a thin wrapper
- **Type-safe**: Full TypeScript inference throughout - **Type-safe**: Full TypeScript inference throughout
@@ -488,6 +514,7 @@ Component → useAuth() hook → AuthContext → Zustand Store → Storage Layer
- **React-idiomatic**: Follows React best practices - **React-idiomatic**: Follows React best practices
**Key Design Principles:** **Key Design Principles:**
1. **Thin Context Layer**: Context only provides dependency injection, no business logic 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) 2. **Zustand for State**: All state management stays in Zustand (no duplicated state)
3. **Backward Compatible**: Internal refactor only, no API changes 3. **Backward Compatible**: Internal refactor only, no API changes
@@ -516,6 +543,7 @@ window.__TEST_AUTH_STORE__ = mockStoreHook;
``` ```
**Implementation Details:** **Implementation Details:**
- Stores Zustand hook function (not state) in Context - Stores Zustand hook function (not state) in Context
- Priority: explicit prop → E2E test store → production singleton - Priority: explicit prop → E2E test store → production singleton
- Type-safe window global extension for E2E injection - Type-safe window global extension for E2E injection
@@ -530,23 +558,25 @@ window.__TEST_AUTH_STORE__ = mockStoreHook;
const { user, isAuthenticated } = useAuth(); const { user, isAuthenticated } = useAuth();
// Pattern 2: Selector (optimized for performance) // Pattern 2: Selector (optimized for performance)
const user = useAuth(state => state.user); const user = useAuth((state) => state.user);
``` ```
**Why Polymorphic?** **Why Polymorphic?**
- Simple pattern for most use cases - Simple pattern for most use cases
- Optimized pattern available when needed - Optimized pattern available when needed
- Type-safe with function overloads - Type-safe with function overloads
- No performance overhead - No performance overhead
**Critical Implementation Detail:** **Critical Implementation Detail:**
```typescript ```typescript
export function useAuth(): AuthState; export function useAuth(): AuthState;
export function useAuth<T>(selector: (state: AuthState) => T): T; export function useAuth<T>(selector: (state: AuthState) => T): T;
export function useAuth<T>(selector?: (state: AuthState) => T): AuthState | T { export function useAuth<T>(selector?: (state: AuthState) => T): AuthState | T {
const storeHook = useContext(AuthContext); const storeHook = useContext(AuthContext);
if (!storeHook) { 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) // CRITICAL: Call the hook internally (follows React Rules of Hooks)
return selector ? storeHook(selector) : storeHook(); return selector ? storeHook(selector) : storeHook();
@@ -580,6 +610,7 @@ function MyComponent() {
``` ```
**Why?** **Why?**
- Component re-renders when auth state changes - Component re-renders when auth state changes
- Type-safe access to all state properties - Type-safe access to all state properties
- Clean, idiomatic React code - Clean, idiomatic React code
@@ -605,6 +636,7 @@ export function useLogin() {
``` ```
**Why?** **Why?**
- Event handlers run outside React render cycle - Event handlers run outside React render cycle
- Don't need to re-render when state changes - Don't need to re-render when state changes
- Using `getState()` directly is cleaner - Using `getState()` directly is cleaner
@@ -694,6 +726,7 @@ test.describe('Protected Pages', () => {
``` ```
**Why This Order?** **Why This Order?**
- AuthProvider must wrap AuthInitializer (AuthInitializer uses auth state) - AuthProvider must wrap AuthInitializer (AuthInitializer uses auth state)
- AuthProvider should wrap all app providers (auth available everywhere) - AuthProvider should wrap all app providers (auth available everywhere)
- Keep provider tree shallow for performance - Keep provider tree shallow for performance
@@ -701,15 +734,18 @@ test.describe('Protected Pages', () => {
### 6.6 Token Management Strategy ### 6.6 Token Management Strategy
**Two-Token System:** **Two-Token System:**
- **Access Token**: Short-lived (15 min), stored in memory/sessionStorage - **Access Token**: Short-lived (15 min), stored in memory/sessionStorage
- **Refresh Token**: Long-lived (7 days), stored in httpOnly cookie (preferred) or localStorage - **Refresh Token**: Long-lived (7 days), stored in httpOnly cookie (preferred) or localStorage
**Token Storage Decision:** **Token Storage Decision:**
- **Primary**: httpOnly cookies (most secure, prevents XSS) - **Primary**: httpOnly cookies (most secure, prevents XSS)
- **Fallback**: localStorage with encryption wrapper (if cookies not feasible) - **Fallback**: localStorage with encryption wrapper (if cookies not feasible)
- **Access Token**: sessionStorage or React state (short-lived, acceptable risk) - **Access Token**: sessionStorage or React state (short-lived, acceptable risk)
**Token Rotation:** **Token Rotation:**
- On refresh, both tokens are rotated - On refresh, both tokens are rotated
- Old refresh token is invalidated immediately - Old refresh token is invalidated immediately
- Prevents token replay attacks - Prevents token replay attacks
@@ -717,6 +753,7 @@ test.describe('Protected Pages', () => {
### 6.2 Per-Device Session Tracking ### 6.2 Per-Device Session Tracking
Backend tracks sessions per device: Backend tracks sessions per device:
- Each login creates a unique session with device info - Each login creates a unique session with device info
- Users can view all active sessions - Users can view all active sessions
- Users can revoke individual sessions - Users can revoke individual sessions
@@ -724,6 +761,7 @@ Backend tracks sessions per device:
- "Logout All" deactivates all sessions - "Logout All" deactivates all sessions
Frontend Implementation: Frontend Implementation:
- Session list page at `/settings/sessions` - Session list page at `/settings/sessions`
- Display device name, IP, location, last used - Display device name, IP, location, last used
- Highlight current session - Highlight current session
@@ -732,6 +770,7 @@ Frontend Implementation:
### 6.3 Auth Guard Implementation ### 6.3 Auth Guard Implementation
**Layout-Based Protection:** **Layout-Based Protection:**
```typescript ```typescript
// app/(authenticated)/layout.tsx // app/(authenticated)/layout.tsx
export default function AuthenticatedLayout({ children }) { export default function AuthenticatedLayout({ children }) {
@@ -746,6 +785,7 @@ export default function AuthenticatedLayout({ children }) {
``` ```
**Permission Checks:** **Permission Checks:**
```typescript ```typescript
// app/(authenticated)/admin/layout.tsx // app/(authenticated)/admin/layout.tsx
export default function AdminLayout({ children }) { export default function AdminLayout({ children }) {
@@ -776,12 +816,14 @@ export default function AdminLayout({ children }) {
### 7.1 OpenAPI Client Generation ### 7.1 OpenAPI Client Generation
**Workflow:** **Workflow:**
``` ```
Backend OpenAPI Spec → @hey-api/openapi-ts → TypeScript Client Backend OpenAPI Spec → @hey-api/openapi-ts → TypeScript Client
(/api/v1/openapi.json) (src/lib/api/generated/) (/api/v1/openapi.json) (src/lib/api/generated/)
``` ```
**Generation Script** (`scripts/generate-api-client.sh`): **Generation Script** (`scripts/generate-api-client.sh`):
```bash ```bash
#!/bin/bash #!/bin/bash
API_URL="${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8000}" API_URL="${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8000}"
@@ -792,6 +834,7 @@ npx @hey-api/openapi-ts \
``` ```
**Benefits:** **Benefits:**
- Type-safe API calls - Type-safe API calls
- Auto-completion in IDE - Auto-completion in IDE
- Compile-time error checking - Compile-time error checking
@@ -801,6 +844,7 @@ npx @hey-api/openapi-ts \
### 7.2 Axios Configuration ### 7.2 Axios Configuration
**Base Instance** (`src/lib/api/client.ts`): **Base Instance** (`src/lib/api/client.ts`):
```typescript ```typescript
export const apiClient = axios.create({ export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL, baseURL: process.env.NEXT_PUBLIC_API_URL,
@@ -812,6 +856,7 @@ export const apiClient = axios.create({
``` ```
**Request Interceptor:** **Request Interceptor:**
```typescript ```typescript
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
(config) => { (config) => {
@@ -826,6 +871,7 @@ apiClient.interceptors.request.use(
``` ```
**Response Interceptor:** **Response Interceptor:**
```typescript ```typescript
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
@@ -850,6 +896,7 @@ apiClient.interceptors.response.use(
### 7.3 Error Handling ### 7.3 Error Handling
**Backend Error Format:** **Backend Error Format:**
```typescript ```typescript
{ {
success: false, success: false,
@@ -864,24 +911,28 @@ apiClient.interceptors.response.use(
``` ```
**Frontend Error Parsing:** **Frontend Error Parsing:**
```typescript ```typescript
export function parseAPIError(error: AxiosError): APIError { export function parseAPIError(error: AxiosError): APIError {
if (error.response?.data?.errors) { if (error.response?.data?.errors) {
return error.response.data.errors; return error.response.data.errors;
} }
return [{ return [
code: 'UNKNOWN', {
message: 'An unexpected error occurred' code: 'UNKNOWN',
}]; message: 'An unexpected error occurred',
},
];
} }
``` ```
**Error Code Mapping:** **Error Code Mapping:**
```typescript ```typescript
const ERROR_MESSAGES = { const ERROR_MESSAGES = {
'AUTH_001': 'Invalid email or password', AUTH_001: 'Invalid email or password',
'USER_002': 'This email is already registered', USER_002: 'This email is already registered',
'VAL_001': 'Please check your input', VAL_001: 'Please check your input',
// ... all backend error codes // ... all backend error codes
}; };
``` ```
@@ -889,6 +940,7 @@ const ERROR_MESSAGES = {
### 7.4 React Query Hooks Pattern ### 7.4 React Query Hooks Pattern
**Standard Pattern:** **Standard Pattern:**
```typescript ```typescript
// lib/api/hooks/useUsers.ts // lib/api/hooks/useUsers.ts
export function useUsers(filters?: UserFilters) { export function useUsers(filters?: UserFilters) {
@@ -955,6 +1007,7 @@ app/
``` ```
**Route Groups** (parentheses in folder name): **Route Groups** (parentheses in folder name):
- Organize routes without affecting URL - Organize routes without affecting URL
- Apply different layouts to route subsets - Apply different layouts to route subsets
- Example: `(auth)` and `(authenticated)` have different layouts - Example: `(auth)` and `(authenticated)` have different layouts
@@ -962,23 +1015,27 @@ app/
### 8.2 Layout Strategy ### 8.2 Layout Strategy
**Root Layout** (`app/layout.tsx`): **Root Layout** (`app/layout.tsx`):
- HTML structure - HTML structure
- React Query provider - React Query provider
- Theme provider - Theme provider
- Global metadata - Global metadata
**Auth Layout** (`app/(auth)/layout.tsx`): **Auth Layout** (`app/(auth)/layout.tsx`):
- Centered form container - Centered form container
- No header/footer - No header/footer
- Minimal styling - Minimal styling
**Authenticated Layout** (`app/(authenticated)/layout.tsx`): **Authenticated Layout** (`app/(authenticated)/layout.tsx`):
- Auth guard (redirect if not authenticated) - Auth guard (redirect if not authenticated)
- Header with user menu - Header with user menu
- Main content area - Main content area
- Footer - Footer
**Admin Layout** (`app/(authenticated)/admin/layout.tsx`): **Admin Layout** (`app/(authenticated)/admin/layout.tsx`):
- Admin sidebar - Admin sidebar
- Breadcrumbs - Breadcrumbs
- Admin permission check (is_superuser) - Admin permission check (is_superuser)
@@ -1036,11 +1093,13 @@ components/
### 9.2 Component Guidelines ### 9.2 Component Guidelines
**Naming:** **Naming:**
- PascalCase for components: `UserTable.tsx` - PascalCase for components: `UserTable.tsx`
- Match file name with component name - Match file name with component name
- One component per file - One component per file
**Structure:** **Structure:**
```typescript ```typescript
// 1. Imports // 1. Imports
import { useState } from 'react'; import { useState } from 'react';
@@ -1078,6 +1137,7 @@ export function UserTable({ filters }: UserTableProps) {
``` ```
**Best Practices:** **Best Practices:**
- Prefer named exports over default exports - Prefer named exports over default exports
- Destructure props in function signature - Destructure props in function signature
- Extract complex logic to hooks - Extract complex logic to hooks
@@ -1087,6 +1147,7 @@ export function UserTable({ filters }: UserTableProps) {
### 9.3 Styling Strategy ### 9.3 Styling Strategy
**Tailwind Utility Classes:** **Tailwind Utility Classes:**
```typescript ```typescript
<button className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90"> <button className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90">
Click Me Click Me
@@ -1094,6 +1155,7 @@ export function UserTable({ filters }: UserTableProps) {
``` ```
**Conditional Classes with cn():** **Conditional Classes with cn():**
```typescript ```typescript
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
@@ -1105,6 +1167,7 @@ import { cn } from '@/lib/utils/cn';
``` ```
**Dark Mode:** **Dark Mode:**
```typescript ```typescript
<div className="bg-white dark:bg-gray-900 text-black dark:text-white"> <div className="bg-white dark:bg-gray-900 text-black dark:text-white">
Content Content
@@ -1134,18 +1197,21 @@ import { cn } from '@/lib/utils/cn';
### 10.2 Test Categories ### 10.2 Test Categories
**Unit Tests** (60% of suite): **Unit Tests** (60% of suite):
- Utilities (`lib/utils/`) - Utilities (`lib/utils/`)
- Custom hooks (`hooks/`) - Custom hooks (`hooks/`)
- Services (`services/`) - Services (`services/`)
- Pure functions - Pure functions
**Component Tests** (30% of suite): **Component Tests** (30% of suite):
- Reusable components (`components/`) - Reusable components (`components/`)
- Forms with validation - Forms with validation
- User interactions - User interactions
- Accessibility - Accessibility
**Integration Tests** (E2E with Playwright, 10% of suite): **Integration Tests** (E2E with Playwright, 10% of suite):
- Critical user flows: - Critical user flows:
- Login → Dashboard - Login → Dashboard
- Admin: Create/Edit/Delete User - Admin: Create/Edit/Delete User
@@ -1157,6 +1223,7 @@ import { cn } from '@/lib/utils/cn';
### 10.3 Testing Tools ### 10.3 Testing Tools
**Jest + React Testing Library:** **Jest + React Testing Library:**
```typescript ```typescript
// UserTable.test.tsx // UserTable.test.tsx
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
@@ -1169,6 +1236,7 @@ test('renders user table with data', async () => {
``` ```
**Playwright E2E:** **Playwright E2E:**
```typescript ```typescript
// tests/e2e/auth.spec.ts // tests/e2e/auth.spec.ts
test('user can login', async ({ page }) => { test('user can login', async ({ page }) => {
@@ -1183,11 +1251,13 @@ test('user can login', async ({ page }) => {
### 10.4 Coverage Target ### 10.4 Coverage Target
**Goal: 90%+ Overall Coverage** **Goal: 90%+ Overall Coverage**
- Unit tests: 95%+ - Unit tests: 95%+
- Component tests: 85%+ - Component tests: 85%+
- Integration tests: Critical paths only - Integration tests: Critical paths only
**Justification for 90%:** **Justification for 90%:**
- This is a template for production projects - This is a template for production projects
- High coverage ensures robustness - High coverage ensures robustness
- Confidence for extension and customization - Confidence for extension and customization
@@ -1199,6 +1269,7 @@ test('user can login', async ({ page }) => {
### 11.1 Optimization Strategies ### 11.1 Optimization Strategies
**Code Splitting:** **Code Splitting:**
```typescript ```typescript
// Dynamic imports for heavy components // Dynamic imports for heavy components
const AdminDashboard = dynamic(() => import('./AdminDashboard'), { const AdminDashboard = dynamic(() => import('./AdminDashboard'), {
@@ -1207,6 +1278,7 @@ const AdminDashboard = dynamic(() => import('./AdminDashboard'), {
``` ```
**Image Optimization:** **Image Optimization:**
```typescript ```typescript
import Image from 'next/image'; import Image from 'next/image';
@@ -1220,11 +1292,13 @@ import Image from 'next/image';
``` ```
**React Query Caching:** **React Query Caching:**
- Stale time: 1 minute (reduce unnecessary refetches) - Stale time: 1 minute (reduce unnecessary refetches)
- Cache time: 5 minutes (keep data in memory) - Cache time: 5 minutes (keep data in memory)
- Background refetch: Yes (keep data fresh) - Background refetch: Yes (keep data fresh)
**Bundle Size Monitoring:** **Bundle Size Monitoring:**
```bash ```bash
npm run build && npm run analyze npm run build && npm run analyze
# Use webpack-bundle-analyzer to identify large dependencies # Use webpack-bundle-analyzer to identify large dependencies
@@ -1233,12 +1307,14 @@ npm run build && npm run analyze
### 11.2 Performance Targets ### 11.2 Performance Targets
**Lighthouse Scores:** **Lighthouse Scores:**
- Performance: >90 - Performance: >90
- Accessibility: 100 - Accessibility: 100
- Best Practices: >90 - Best Practices: >90
- SEO: >90 - SEO: >90
**Core Web Vitals:** **Core Web Vitals:**
- LCP (Largest Contentful Paint): <2.5s - LCP (Largest Contentful Paint): <2.5s
- FID (First Input Delay): <100ms - FID (First Input Delay): <100ms
- CLS (Cumulative Layout Shift): <0.1 - CLS (Cumulative Layout Shift): <0.1
@@ -1250,16 +1326,19 @@ npm run build && npm run analyze
### 12.1 Client-Side Security ### 12.1 Client-Side Security
**XSS Prevention:** **XSS Prevention:**
- React's default escaping (JSX) - React's default escaping (JSX)
- Sanitize user input if rendering HTML - Sanitize user input if rendering HTML
- CSP headers (configured in backend) - CSP headers (configured in backend)
**Token Security:** **Token Security:**
- Access token: sessionStorage or memory (15 min expiry mitigates risk) - Access token: sessionStorage or memory (15 min expiry mitigates risk)
- Refresh token: httpOnly cookie (preferred) or encrypted localStorage - Refresh token: httpOnly cookie (preferred) or encrypted localStorage
- Never log tokens to console in production - Never log tokens to console in production
**HTTPS Only:** **HTTPS Only:**
- All production requests over HTTPS - All production requests over HTTPS
- Cookies with Secure flag - Cookies with Secure flag
- No mixed content - No mixed content
@@ -1267,11 +1346,13 @@ npm run build && npm run analyze
### 12.2 Input Validation ### 12.2 Input Validation
**Client-Side Validation:** **Client-Side Validation:**
- Zod schemas for all forms - Zod schemas for all forms
- Immediate feedback to users - Immediate feedback to users
- Prevent malformed requests - Prevent malformed requests
**Remember:** **Remember:**
- Client validation is for UX - Client validation is for UX
- Backend validation is for security - Backend validation is for security
- Always trust backend, not client - Always trust backend, not client
@@ -1279,12 +1360,14 @@ npm run build && npm run analyze
### 12.3 Dependency Security ### 12.3 Dependency Security
**Regular Audits:** **Regular Audits:**
```bash ```bash
npm audit npm audit
npm audit fix npm audit fix
``` ```
**Automated Scanning:** **Automated Scanning:**
- Dependabot (GitHub) - Dependabot (GitHub)
- Snyk (CI/CD integration) - Snyk (CI/CD integration)
@@ -1295,12 +1378,14 @@ npm audit fix
### 13.1 Why Next.js App Router? ### 13.1 Why Next.js App Router?
**Pros:** **Pros:**
- Server Components reduce client bundle - Server Components reduce client bundle
- Better data fetching patterns - Better data fetching patterns
- Streaming and Suspense built-in - Streaming and Suspense built-in
- Simpler layouts and error handling - Simpler layouts and error handling
**Cons:** **Cons:**
- Newer, less mature than Pages Router - Newer, less mature than Pages Router
- Learning curve for team - Learning curve for team
@@ -1309,11 +1394,13 @@ npm audit fix
### 13.2 Why TanStack Query? ### 13.2 Why TanStack Query?
**Alternatives Considered:** **Alternatives Considered:**
- SWR: Similar but less features - SWR: Similar but less features
- Redux Toolkit Query: Too much boilerplate for our use case - Redux Toolkit Query: Too much boilerplate for our use case
- Apollo Client: Overkill for REST API - Apollo Client: Overkill for REST API
**Why TanStack Query:** **Why TanStack Query:**
- Best-in-class caching and refetching - Best-in-class caching and refetching
- Framework-agnostic (not tied to Next.js) - Framework-agnostic (not tied to Next.js)
- Excellent DevTools - Excellent DevTools
@@ -1322,11 +1409,13 @@ npm audit fix
### 13.3 Why Zustand over Redux? ### 13.3 Why Zustand over Redux?
**Why NOT Redux:** **Why NOT Redux:**
- Too much boilerplate (actions, reducers, middleware) - Too much boilerplate (actions, reducers, middleware)
- We don't need time-travel debugging - We don't need time-travel debugging
- Most state is server state (handled by React Query) - Most state is server state (handled by React Query)
**Why Zustand:** **Why Zustand:**
- Minimal API (easy to learn) - Minimal API (easy to learn)
- No Context API overhead - No Context API overhead
- Can use outside React (interceptors) - Can use outside React (interceptors)
@@ -1335,11 +1424,13 @@ npm audit fix
### 13.4 Why shadcn/ui over Component Libraries? ### 13.4 Why shadcn/ui over Component Libraries?
**Alternatives Considered:** **Alternatives Considered:**
- Material-UI: Heavy, opinionated styling - Material-UI: Heavy, opinionated styling
- Chakra UI: Good, but still an npm dependency - Chakra UI: Good, but still an npm dependency
- Ant Design: Too opinionated for template - Ant Design: Too opinionated for template
**Why shadcn/ui:** **Why shadcn/ui:**
- Copy-paste (full control) - Copy-paste (full control)
- Accessible (Radix UI primitives) - Accessible (Radix UI primitives)
- Tailwind-based (consistent with our stack) - Tailwind-based (consistent with our stack)
@@ -1348,11 +1439,13 @@ npm audit fix
### 13.5 Why Axios over Fetch? ### 13.5 Why Axios over Fetch?
**Why NOT Fetch:** **Why NOT Fetch:**
- No request/response interceptors - No request/response interceptors
- Manual timeout handling - Manual timeout handling
- Less ergonomic error handling - Less ergonomic error handling
**Why Axios:** **Why Axios:**
- Interceptors (essential for auth) - Interceptors (essential for auth)
- Automatic JSON parsing - Automatic JSON parsing
- Better error handling - Better error handling
@@ -1364,11 +1457,13 @@ npm audit fix
**Decision: httpOnly Cookies (Primary), localStorage (Fallback)** **Decision: httpOnly Cookies (Primary), localStorage (Fallback)**
**Why httpOnly Cookies:** **Why httpOnly Cookies:**
- Most secure (not accessible to JavaScript) - Most secure (not accessible to JavaScript)
- Prevents XSS token theft - Prevents XSS token theft
- Automatic sending with requests (if CORS configured) - Automatic sending with requests (if CORS configured)
**Why Fallback to localStorage:** **Why Fallback to localStorage:**
- Simpler initial setup (no backend cookie handling) - Simpler initial setup (no backend cookie handling)
- Still secure with proper measures: - Still secure with proper measures:
- Short access token expiry (15 min) - Short access token expiry (15 min)
@@ -1377,6 +1472,7 @@ npm audit fix
- Encrypted wrapper (optional) - Encrypted wrapper (optional)
**Implementation:** **Implementation:**
- Try httpOnly cookies first - Try httpOnly cookies first
- Fall back to localStorage if not feasible - Fall back to localStorage if not feasible
- Document choice in code - Document choice in code
@@ -1388,12 +1484,14 @@ npm audit fix
### 14.1 Production Deployment ### 14.1 Production Deployment
**Recommended Platform: Vercel** **Recommended Platform: Vercel**
- Native Next.js support - Native Next.js support
- Edge functions for middleware - Edge functions for middleware
- Automatic preview deployments - Automatic preview deployments
- CDN with global edge network - CDN with global edge network
**Alternative: Docker** **Alternative: Docker**
```dockerfile ```dockerfile
FROM node:20-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
@@ -1408,18 +1506,21 @@ CMD ["npm", "start"]
### 14.2 Environment Configuration ### 14.2 Environment Configuration
**Development:** **Development:**
```env ```env
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1 NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
NODE_ENV=development NODE_ENV=development
``` ```
**Production:** **Production:**
```env ```env
NEXT_PUBLIC_API_URL=https://api.example.com/api/v1 NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
NODE_ENV=production NODE_ENV=production
``` ```
**Secrets:** **Secrets:**
- Never commit `.env.local` - Never commit `.env.local`
- Use platform-specific secret management (Vercel Secrets, Docker Secrets) - 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. 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: For specific implementation details, refer to:
- **CODING_STANDARDS.md**: Code style and conventions - **CODING_STANDARDS.md**: Code style and conventions
- **COMPONENT_GUIDE.md**: Component usage and patterns - **COMPONENT_GUIDE.md**: Component usage and patterns
- **FEATURE_EXAMPLES.md**: Step-by-step feature implementation - **FEATURE_EXAMPLES.md**: Step-by-step feature implementation

View File

@@ -31,6 +31,7 @@
### 1.1 General Principles ### 1.1 General Principles
**✅ DO:** **✅ DO:**
- Enable strict mode in `tsconfig.json` - Enable strict mode in `tsconfig.json`
- Define explicit types for all function parameters and return values - Define explicit types for all function parameters and return values
- Use TypeScript's type inference where obvious - Use TypeScript's type inference where obvious
@@ -38,6 +39,7 @@
- Use generics for reusable, type-safe components and functions - Use generics for reusable, type-safe components and functions
**❌ DON'T:** **❌ DON'T:**
- Use `any` (use `unknown` if type is truly unknown) - Use `any` (use `unknown` if type is truly unknown)
- Use `as any` casts (refactor to proper types) - Use `as any` casts (refactor to proper types)
- Use `@ts-ignore` or `@ts-nocheck` (fix the underlying issue) - Use `@ts-ignore` or `@ts-nocheck` (fix the underlying issue)
@@ -46,6 +48,7 @@
### 1.2 Interfaces vs Types ### 1.2 Interfaces vs Types
**Use `interface` for object shapes:** **Use `interface` for object shapes:**
```typescript ```typescript
// ✅ Good // ✅ Good
interface User { interface User {
@@ -63,6 +66,7 @@ interface UserFormProps {
``` ```
**Use `type` for unions, intersections, and primitives:** **Use `type` for unions, intersections, and primitives:**
```typescript ```typescript
// ✅ Good // ✅ Good
type UserRole = 'admin' | 'user' | 'guest'; type UserRole = 'admin' | 'user' | 'guest';
@@ -74,6 +78,7 @@ type UserWithPermissions = User & { permissions: string[] };
### 1.3 Function Signatures ### 1.3 Function Signatures
**Always type function parameters and return values:** **Always type function parameters and return values:**
```typescript ```typescript
// ✅ Good // ✅ Good
function formatUserName(user: User): string { function formatUserName(user: User): string {
@@ -86,7 +91,8 @@ async function fetchUser(id: string): Promise<User> {
} }
// ❌ Bad // ❌ Bad
function formatUserName(user) { // Implicit any function formatUserName(user) {
// Implicit any
return `${user.firstName} ${user.lastName}`; return `${user.firstName} ${user.lastName}`;
} }
``` ```
@@ -94,6 +100,7 @@ function formatUserName(user) { // Implicit any
### 1.4 Generics ### 1.4 Generics
**Use generics for reusable, type-safe code:** **Use generics for reusable, type-safe code:**
```typescript ```typescript
// ✅ Good: Generic data table // ✅ Good: Generic data table
interface DataTableProps<T> { interface DataTableProps<T> {
@@ -113,6 +120,7 @@ export function DataTable<T>({ data, columns, onRowClick }: DataTableProps<T>) {
### 1.5 Unknown vs Any ### 1.5 Unknown vs Any
**Use `unknown` for truly unknown types:** **Use `unknown` for truly unknown types:**
```typescript ```typescript
// ✅ Good: Force type checking // ✅ Good: Force type checking
function parseJSON(jsonString: string): unknown { function parseJSON(jsonString: string): unknown {
@@ -134,15 +142,11 @@ function parseJSON(jsonString: string): any {
### 1.6 Type Guards ### 1.6 Type Guards
**Create type guards for runtime type checking:** **Create type guards for runtime type checking:**
```typescript ```typescript
// ✅ Good // ✅ Good
function isUser(value: unknown): value is User { function isUser(value: unknown): value is User {
return ( return typeof value === 'object' && value !== null && 'id' in value && 'email' in value;
typeof value === 'object' &&
value !== null &&
'id' in value &&
'email' in value
);
} }
// Usage // Usage
@@ -155,6 +159,7 @@ if (isUser(data)) {
### 1.7 Utility Types ### 1.7 Utility Types
**Use TypeScript utility types:** **Use TypeScript utility types:**
```typescript ```typescript
// Partial - make all properties optional // Partial - make all properties optional
type UserUpdate = Partial<User>; type UserUpdate = Partial<User>;
@@ -182,6 +187,7 @@ type NonAdminRole = Exclude<UserRole, 'admin'>;
### 2.1 Component Structure ### 2.1 Component Structure
**Standard component template:** **Standard component template:**
```typescript ```typescript
// 1. Imports (React, external libs, internal, types, styles) // 1. Imports (React, external libs, internal, types, styles)
'use client'; // If client component 'use client'; // If client component
@@ -240,6 +246,7 @@ export function UserList({
### 2.2 Server Components vs Client Components ### 2.2 Server Components vs Client Components
**Server Components by default:** **Server Components by default:**
```typescript ```typescript
// ✅ Good: Server Component (default) // ✅ Good: Server Component (default)
// app/(authenticated)/users/page.tsx // app/(authenticated)/users/page.tsx
@@ -255,6 +262,7 @@ export default async function UsersPage() {
``` ```
**Client Components only when needed:** **Client Components only when needed:**
```typescript ```typescript
// ✅ Good: Client Component (interactive) // ✅ Good: Client Component (interactive)
'use client'; 'use client';
@@ -276,6 +284,7 @@ export function UserList() {
``` ```
**When to use Client Components:** **When to use Client Components:**
- Using React hooks (useState, useEffect, etc.) - Using React hooks (useState, useEffect, etc.)
- Event handlers (onClick, onChange, etc.) - Event handlers (onClick, onChange, etc.)
- Browser APIs (window, document, localStorage) - Browser APIs (window, document, localStorage)
@@ -284,6 +293,7 @@ export function UserList() {
### 2.3 Props ### 2.3 Props
**Always type props explicitly:** **Always type props explicitly:**
```typescript ```typescript
// ✅ Good: Explicit interface // ✅ Good: Explicit interface
interface ButtonProps { interface ButtonProps {
@@ -300,13 +310,14 @@ export function Button({
onClick, onClick,
variant = 'primary', variant = 'primary',
disabled = false, disabled = false,
className className,
}: ButtonProps) { }: ButtonProps) {
// Implementation // Implementation
} }
``` ```
**Destructure props in function signature:** **Destructure props in function signature:**
```typescript ```typescript
// ✅ Good // ✅ Good
function UserCard({ user, onEdit }: UserCardProps) { function UserCard({ user, onEdit }: UserCardProps) {
@@ -320,6 +331,7 @@ function UserCard(props: UserCardProps) {
``` ```
**Allow className override:** **Allow className override:**
```typescript ```typescript
// ✅ Good: Allow consumers to add classes // ✅ Good: Allow consumers to add classes
interface CardProps { interface CardProps {
@@ -339,6 +351,7 @@ export function Card({ title, className }: CardProps) {
### 2.4 Composition Over Prop Drilling ### 2.4 Composition Over Prop Drilling
**Use composition patterns:** **Use composition patterns:**
```typescript ```typescript
// ✅ Good: Composition // ✅ Good: Composition
<Card> <Card>
@@ -366,6 +379,7 @@ export function Card({ title, className }: CardProps) {
### 2.5 Named Exports vs Default Exports ### 2.5 Named Exports vs Default Exports
**Prefer named exports:** **Prefer named exports:**
```typescript ```typescript
// ✅ Good: Named export // ✅ Good: Named export
export function UserList() { export function UserList() {
@@ -385,6 +399,7 @@ import WhateverName from './UserList';
``` ```
**Exception: Next.js pages must use default export** **Exception: Next.js pages must use default export**
```typescript ```typescript
// pages and route handlers require default export // pages and route handlers require default export
export default function UsersPage() { export default function UsersPage() {
@@ -395,6 +410,7 @@ export default function UsersPage() {
### 2.6 Custom Hooks ### 2.6 Custom Hooks
**Extract reusable logic:** **Extract reusable logic:**
```typescript ```typescript
// ✅ Good: Custom hook // ✅ Good: Custom hook
function useDebounce<T>(value: T, delay: number): T { function useDebounce<T>(value: T, delay: number): T {
@@ -425,15 +441,16 @@ function SearchInput() {
``` ```
**Hook naming: Always prefix with "use":** **Hook naming: Always prefix with "use":**
```typescript ```typescript
// ✅ Good // ✅ Good
function useAuth() { } function useAuth() {}
function useUsers() { } function useUsers() {}
function useDebounce() { } function useDebounce() {}
// ❌ Bad // ❌ Bad
function auth() { } function auth() {}
function getUsers() { } function getUsers() {}
``` ```
--- ---
@@ -442,20 +459,21 @@ function getUsers() { }
### 3.1 Files and Directories ### 3.1 Files and Directories
| Type | Convention | Example | | Type | Convention | Example |
|------|------------|---------| | --------------- | ------------------------------- | --------------------------------------- |
| Components | PascalCase | `UserTable.tsx`, `LoginForm.tsx` | | Components | PascalCase | `UserTable.tsx`, `LoginForm.tsx` |
| Hooks | camelCase with `use` prefix | `useAuth.ts`, `useDebounce.ts` | | Hooks | camelCase with `use` prefix | `useAuth.ts`, `useDebounce.ts` |
| Utilities | camelCase | `formatDate.ts`, `parseError.ts` | | Utilities | camelCase | `formatDate.ts`, `parseError.ts` |
| Types | camelCase | `user.ts`, `api.ts` | | Types | camelCase | `user.ts`, `api.ts` |
| Constants | camelCase or UPPER_SNAKE_CASE | `constants.ts`, `API_ENDPOINTS.ts` | | Constants | camelCase or UPPER_SNAKE_CASE | `constants.ts`, `API_ENDPOINTS.ts` |
| Stores | camelCase with `Store` suffix | `authStore.ts`, `uiStore.ts` | | Stores | camelCase with `Store` suffix | `authStore.ts`, `uiStore.ts` |
| Services | camelCase with `Service` suffix | `authService.ts`, `adminService.ts` | | Services | camelCase with `Service` suffix | `authService.ts`, `adminService.ts` |
| Pages (Next.js) | lowercase | `page.tsx`, `layout.tsx`, `loading.tsx` | | Pages (Next.js) | lowercase | `page.tsx`, `layout.tsx`, `loading.tsx` |
### 3.2 Variables and Functions ### 3.2 Variables and Functions
**Variables:** **Variables:**
```typescript ```typescript
// ✅ Good: camelCase // ✅ Good: camelCase
const userName = 'John'; const userName = 'John';
@@ -463,36 +481,39 @@ const isAuthenticated = true;
const userList = []; const userList = [];
// ❌ Bad // ❌ Bad
const UserName = 'John'; // PascalCase for variable const UserName = 'John'; // PascalCase for variable
const user_name = 'John'; // snake_case const user_name = 'John'; // snake_case
``` ```
**Functions:** **Functions:**
```typescript ```typescript
// ✅ Good: camelCase, descriptive verb + noun // ✅ Good: camelCase, descriptive verb + noun
function getUserById(id: string): User { } function getUserById(id: string): User {}
function formatDate(date: Date): string { } function formatDate(date: Date): string {}
function handleSubmit(data: FormData): void { } function handleSubmit(data: FormData): void {}
// ❌ Bad // ❌ Bad
function User(id: string) { } // Looks like a class function User(id: string) {} // Looks like a class
function get_user(id: string) { } // snake_case function get_user(id: string) {} // snake_case
function gub(id: string) { } // Not descriptive function gub(id: string) {} // Not descriptive
``` ```
**Event Handlers:** **Event Handlers:**
```typescript ```typescript
// ✅ Good: handle + EventName // ✅ Good: handle + EventName
const handleClick = () => { }; const handleClick = () => {};
const handleSubmit = () => { }; const handleSubmit = () => {};
const handleInputChange = () => { }; const handleInputChange = () => {};
// ❌ Bad // ❌ Bad
const onClick = () => { }; // Confusing with prop name const onClick = () => {}; // Confusing with prop name
const submit = () => { }; const submit = () => {};
``` ```
**Boolean Variables:** **Boolean Variables:**
```typescript ```typescript
// ✅ Good: is/has/should prefix // ✅ Good: is/has/should prefix
const isLoading = true; const isLoading = true;
@@ -508,6 +529,7 @@ const error = false;
### 3.3 Constants ### 3.3 Constants
**Use UPPER_SNAKE_CASE for true constants:** **Use UPPER_SNAKE_CASE for true constants:**
```typescript ```typescript
// ✅ Good // ✅ Good
const MAX_RETRY_ATTEMPTS = 3; const MAX_RETRY_ATTEMPTS = 3;
@@ -525,16 +547,17 @@ const USER_ROLES = {
### 3.4 Types and Interfaces ### 3.4 Types and Interfaces
**PascalCase for types and interfaces:** **PascalCase for types and interfaces:**
```typescript ```typescript
// ✅ Good // ✅ Good
interface User { } interface User {}
interface UserFormProps { } interface UserFormProps {}
type UserId = string; type UserId = string;
type UserRole = 'admin' | 'user'; type UserRole = 'admin' | 'user';
// ❌ Bad // ❌ Bad
interface user { } interface user {}
interface user_form_props { } interface user_form_props {}
type userId = string; type userId = string;
``` ```
@@ -545,6 +568,7 @@ type userId = string;
### 4.1 Import Order ### 4.1 Import Order
**Organize imports in this order:** **Organize imports in this order:**
```typescript ```typescript
// 1. React and Next.js // 1. React and Next.js
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
@@ -576,6 +600,7 @@ import styles from './Component.module.css';
### 4.2 Co-location ### 4.2 Co-location
**Group related files together:** **Group related files together:**
``` ```
components/admin/ components/admin/
├── UserTable/ ├── UserTable/
@@ -587,6 +612,7 @@ components/admin/
``` ```
**Or flat structure for simpler components:** **Or flat structure for simpler components:**
``` ```
components/admin/ components/admin/
├── UserTable.tsx ├── UserTable.tsx
@@ -598,6 +624,7 @@ components/admin/
### 4.3 Barrel Exports ### 4.3 Barrel Exports
**Use index.ts for clean imports:** **Use index.ts for clean imports:**
```typescript ```typescript
// components/ui/index.ts // components/ui/index.ts
export { Button } from './button'; export { Button } from './button';
@@ -615,6 +642,7 @@ import { Button, Card, Input } from '@/components/ui';
### 5.1 State Placement ### 5.1 State Placement
**Keep state as local as possible:** **Keep state as local as possible:**
```typescript ```typescript
// ✅ Good: Local state // ✅ Good: Local state
function UserFilter() { function UserFilter() {
@@ -629,6 +657,7 @@ function UserFilter() {
### 5.2 TanStack Query Usage ### 5.2 TanStack Query Usage
**Standard query pattern:** **Standard query pattern:**
```typescript ```typescript
// lib/api/hooks/useUsers.ts // lib/api/hooks/useUsers.ts
export function useUsers(filters?: UserFilters) { export function useUsers(filters?: UserFilters) {
@@ -651,6 +680,7 @@ function UserList() {
``` ```
**Standard mutation pattern:** **Standard mutation pattern:**
```typescript ```typescript
// lib/api/hooks/useUsers.ts // lib/api/hooks/useUsers.ts
export function useUpdateUser() { export function useUpdateUser() {
@@ -691,22 +721,21 @@ function UserForm({ userId }: { userId: string }) {
``` ```
**Query key structure:** **Query key structure:**
```typescript ```typescript
// ✅ Good: Consistent query keys // ✅ Good: Consistent query keys
['users'] // List all ['users'][('users', userId)][('users', { search: 'john', page: 1 })][ // List all // Single user // Filtered list
['users', userId] // Single user ('organizations', orgId, 'members')
['users', { search: 'john', page: 1 }] // Filtered list ][ // Nested resource
['organizations', orgId, 'members'] // Nested resource // ❌ Bad: Inconsistent
'userList'
// ❌ Bad: Inconsistent ]['user-' + userId][('getUsersBySearch', 'john')];
['userList']
['user-' + userId]
['getUsersBySearch', 'john']
``` ```
### 5.3 Zustand Store Pattern ### 5.3 Zustand Store Pattern
**Auth store example:** **Auth store example:**
```typescript ```typescript
// stores/authStore.ts // stores/authStore.ts
import { create } from 'zustand'; import { create } from 'zustand';
@@ -757,6 +786,7 @@ function UserAvatar() {
``` ```
**UI store example:** **UI store example:**
```typescript ```typescript
// stores/uiStore.ts // stores/uiStore.ts
interface UIStore { interface UIStore {
@@ -790,6 +820,7 @@ export const useUIStore = create<UIStore>()(
### 6.1 API Client Structure ### 6.1 API Client Structure
**Axios instance configuration:** **Axios instance configuration:**
```typescript ```typescript
// lib/api/client.ts // lib/api/client.ts
import axios from 'axios'; import axios from 'axios';
@@ -837,6 +868,7 @@ apiClient.interceptors.response.use(
### 6.2 Error Handling ### 6.2 Error Handling
**Parse API errors:** **Parse API errors:**
```typescript ```typescript
// lib/api/errors.ts // lib/api/errors.ts
export interface APIError { export interface APIError {
@@ -850,18 +882,20 @@ export function parseAPIError(error: AxiosError): APIError[] {
return error.response.data.errors; return error.response.data.errors;
} }
return [{ return [
code: 'UNKNOWN', {
message: error.message || 'An unexpected error occurred', code: 'UNKNOWN',
}]; message: error.message || 'An unexpected error occurred',
},
];
} }
// Error code mapping // Error code mapping
export const ERROR_MESSAGES: Record<string, string> = { export const ERROR_MESSAGES: Record<string, string> = {
'AUTH_001': 'Invalid email or password', AUTH_001: 'Invalid email or password',
'USER_002': 'This email is already registered', USER_002: 'This email is already registered',
'VAL_001': 'Please check your input', VAL_001: 'Please check your input',
'ORG_001': 'Organization name already exists', ORG_001: 'Organization name already exists',
}; };
export function getErrorMessage(code: string): string { export function getErrorMessage(code: string): string {
@@ -872,18 +906,19 @@ export function getErrorMessage(code: string): string {
### 6.3 Hook Organization ### 6.3 Hook Organization
**One hook file per resource:** **One hook file per resource:**
```typescript ```typescript
// lib/api/hooks/useUsers.ts // lib/api/hooks/useUsers.ts
export function useUsers(filters?: UserFilters) { } export function useUsers(filters?: UserFilters) {}
export function useUser(userId: string) { } export function useUser(userId: string) {}
export function useCreateUser() { } export function useCreateUser() {}
export function useUpdateUser() { } export function useUpdateUser() {}
export function useDeleteUser() { } export function useDeleteUser() {}
// lib/api/hooks/useOrganizations.ts // lib/api/hooks/useOrganizations.ts
export function useOrganizations() { } export function useOrganizations() {}
export function useOrganization(orgId: string) { } export function useOrganization(orgId: string) {}
export function useCreateOrganization() { } export function useCreateOrganization() {}
// ... // ...
``` ```
@@ -894,6 +929,7 @@ export function useCreateOrganization() { }
### 7.1 Form Pattern with react-hook-form + Zod ### 7.1 Form Pattern with react-hook-form + Zod
**Standard form implementation:** **Standard form implementation:**
```typescript ```typescript
// components/auth/LoginForm.tsx // components/auth/LoginForm.tsx
'use client'; 'use client';
@@ -961,30 +997,34 @@ export function LoginForm() {
### 7.2 Form Validation ### 7.2 Form Validation
**Complex validation with Zod:** **Complex validation with Zod:**
```typescript ```typescript
const userSchema = z.object({ const userSchema = z
email: z.string().email('Invalid email'), .object({
password: z email: z.string().email('Invalid email'),
.string() password: z
.min(8, 'Min 8 characters') .string()
.regex(/[A-Z]/, 'Must contain uppercase') .min(8, 'Min 8 characters')
.regex(/[0-9]/, 'Must contain number'), .regex(/[A-Z]/, 'Must contain uppercase')
confirmPassword: z.string(), .regex(/[0-9]/, 'Must contain number'),
firstName: z.string().min(1, 'Required'), confirmPassword: z.string(),
lastName: z.string().optional(), firstName: z.string().min(1, 'Required'),
phoneNumber: z lastName: z.string().optional(),
.string() phoneNumber: z
.regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number') .string()
.optional(), .regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number')
}).refine((data) => data.password === data.confirmPassword, { .optional(),
message: 'Passwords do not match', })
path: ['confirmPassword'], .refine((data) => data.password === data.confirmPassword, {
}); message: 'Passwords do not match',
path: ['confirmPassword'],
});
``` ```
### 7.3 Form Accessibility ### 7.3 Form Accessibility
**Always include labels and error messages:** **Always include labels and error messages:**
```typescript ```typescript
<div> <div>
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
@@ -1009,6 +1049,7 @@ const userSchema = z.object({
### 8.1 Tailwind CSS Usage ### 8.1 Tailwind CSS Usage
**Use utility classes:** **Use utility classes:**
```typescript ```typescript
// ✅ Good // ✅ Good
<button className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90"> <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:** **Use cn() for conditional classes:**
```typescript ```typescript
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
@@ -1036,6 +1078,7 @@ import { cn } from '@/lib/utils/cn';
### 8.2 Responsive Design ### 8.2 Responsive Design
**Mobile-first approach:** **Mobile-first approach:**
```typescript ```typescript
<div className=" <div className="
w-full p-4 /* Mobile */ w-full p-4 /* Mobile */
@@ -1050,6 +1093,7 @@ import { cn } from '@/lib/utils/cn';
### 8.3 Dark Mode ### 8.3 Dark Mode
**Use dark mode classes:** **Use dark mode classes:**
```typescript ```typescript
<div className=" <div className="
bg-white text-black bg-white text-black
@@ -1079,6 +1123,7 @@ src/
### 10.2 Test Naming ### 10.2 Test Naming
**Use descriptive test names:** **Use descriptive test names:**
```typescript ```typescript
// ✅ Good // ✅ Good
test('displays user list when data is loaded', async () => {}); test('displays user list when data is loaded', async () => {});
@@ -1095,6 +1140,7 @@ test('renders', () => {});
### 10.3 Component Testing ### 10.3 Component Testing
**Test user interactions, not implementation:** **Test user interactions, not implementation:**
```typescript ```typescript
// UserTable.test.tsx // UserTable.test.tsx
import { render, screen, userEvent } from '@testing-library/react'; import { render, screen, userEvent } from '@testing-library/react';
@@ -1115,6 +1161,7 @@ test('allows user to search for users', async () => {
### 10.4 Accessibility Testing ### 10.4 Accessibility Testing
**Test with accessibility queries:** **Test with accessibility queries:**
```typescript ```typescript
// Prefer getByRole over getByTestId // Prefer getByRole over getByTestId
const button = screen.getByRole('button', { name: 'Submit' }); const button = screen.getByRole('button', { name: 'Submit' });
@@ -1156,6 +1203,7 @@ const textbox = screen.getByRole('textbox', { name: 'Email' });
### 11.2 ARIA Labels ### 11.2 ARIA Labels
**Use ARIA when semantic HTML isn't enough:** **Use ARIA when semantic HTML isn't enough:**
```typescript ```typescript
<button aria-label="Close dialog"> <button aria-label="Close dialog">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
@@ -1169,6 +1217,7 @@ const textbox = screen.getByRole('textbox', { name: 'Email' });
### 11.3 Keyboard Navigation ### 11.3 Keyboard Navigation
**Ensure all interactive elements are keyboard accessible:** **Ensure all interactive elements are keyboard accessible:**
```typescript ```typescript
<div <div
role="button" role="button"
@@ -1191,6 +1240,7 @@ const textbox = screen.getByRole('textbox', { name: 'Email' });
### 12.1 Code Splitting ### 12.1 Code Splitting
**Dynamic imports for heavy components:** **Dynamic imports for heavy components:**
```typescript ```typescript
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
@@ -1203,6 +1253,7 @@ const HeavyChart = dynamic(() => import('./HeavyChart'), {
### 12.2 Memoization ### 12.2 Memoization
**Use React.memo for expensive renders:** **Use React.memo for expensive renders:**
```typescript ```typescript
export const UserCard = React.memo(function UserCard({ user }: UserCardProps) { export const UserCard = React.memo(function UserCard({ user }: UserCardProps) {
return <div>{user.name}</div>; return <div>{user.name}</div>;
@@ -1210,6 +1261,7 @@ export const UserCard = React.memo(function UserCard({ user }: UserCardProps) {
``` ```
**Use useMemo for expensive calculations:** **Use useMemo for expensive calculations:**
```typescript ```typescript
const sortedUsers = useMemo(() => { const sortedUsers = useMemo(() => {
return users.sort((a, b) => a.name.localeCompare(b.name)); return users.sort((a, b) => a.name.localeCompare(b.name));
@@ -1219,6 +1271,7 @@ const sortedUsers = useMemo(() => {
### 12.3 Image Optimization ### 12.3 Image Optimization
**Always use Next.js Image component:** **Always use Next.js Image component:**
```typescript ```typescript
import Image from 'next/image'; import Image from 'next/image';
@@ -1238,6 +1291,7 @@ import Image from 'next/image';
### 13.1 Input Sanitization ### 13.1 Input Sanitization
**Never render raw HTML:** **Never render raw HTML:**
```typescript ```typescript
// ✅ Good: React escapes by default // ✅ Good: React escapes by default
<div>{userInput}</div> <div>{userInput}</div>
@@ -1249,6 +1303,7 @@ import Image from 'next/image';
### 13.2 Environment Variables ### 13.2 Environment Variables
**Never commit secrets:** **Never commit secrets:**
```typescript ```typescript
// ✅ Good: Use env variables // ✅ Good: Use env variables
const apiUrl = process.env.NEXT_PUBLIC_API_URL; const apiUrl = process.env.NEXT_PUBLIC_API_URL;
@@ -1258,6 +1313,7 @@ const apiUrl = 'https://api.example.com';
``` ```
**Public vs Private:** **Public vs Private:**
- `NEXT_PUBLIC_*`: Exposed to browser - `NEXT_PUBLIC_*`: Exposed to browser
- Other vars: Server-side only - Other vars: Server-side only
@@ -1266,6 +1322,7 @@ const apiUrl = 'https://api.example.com';
## 14. Code Review Checklist ## 14. Code Review Checklist
**Before submitting PR:** **Before submitting PR:**
- [ ] All tests pass - [ ] All tests pass
- [ ] No TypeScript errors - [ ] No TypeScript errors
- [ ] ESLint passes - [ ] 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. 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: For specific patterns and examples, refer to:
- **ARCHITECTURE.md**: System design and patterns - **ARCHITECTURE.md**: System design and patterns
- **COMPONENT_GUIDE.md**: Component usage and examples - **COMPONENT_GUIDE.md**: Component usage and examples
- **FEATURE_EXAMPLES.md**: Step-by-step implementation guides - **FEATURE_EXAMPLES.md**: Step-by-step implementation guides

View File

@@ -27,44 +27,48 @@
### Pitfall 1.1: Returning Hook Function Instead of Calling It ### Pitfall 1.1: Returning Hook Function Instead of Calling It
**❌ WRONG:** **❌ WRONG:**
```typescript ```typescript
// Custom hook that wraps Zustand // Custom hook that wraps Zustand
export function useAuth() { export function useAuth() {
const storeHook = useContext(AuthContext); const storeHook = useContext(AuthContext);
return storeHook; // Returns the hook function itself! return storeHook; // Returns the hook function itself!
} }
// Consumer component // Consumer component
function MyComponent() { function MyComponent() {
const authHook = useAuth(); // Got the hook function const authHook = useAuth(); // Got the hook function
const { user } = authHook(); // Have to call it here ❌ Rules of Hooks violation! const { user } = authHook(); // Have to call it here ❌ Rules of Hooks violation!
} }
``` ```
**Why It's Wrong:** **Why It's Wrong:**
- Violates React Rules of Hooks (hook called conditionally/in wrong place) - Violates React Rules of Hooks (hook called conditionally/in wrong place)
- Confusing API for consumers - Confusing API for consumers
- Can't use in conditionals or callbacks safely - Can't use in conditionals or callbacks safely
- Type inference breaks - Type inference breaks
**✅ CORRECT:** **✅ CORRECT:**
```typescript ```typescript
// Custom hook that calls the wrapped hook internally // Custom hook that calls the wrapped hook internally
export function useAuth() { export function useAuth() {
const storeHook = useContext(AuthContext); const storeHook = useContext(AuthContext);
if (!storeHook) { 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 // Consumer component
function MyComponent() { function MyComponent() {
const { user } = useAuth(); // Direct access to state ✅ const { user } = useAuth(); // Direct access to state ✅
} }
``` ```
**✅ EVEN BETTER (Polymorphic):** **✅ EVEN BETTER (Polymorphic):**
```typescript ```typescript
// Support both patterns // Support both patterns
export function useAuth(): AuthState; 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 { export function useAuth<T>(selector?: (state: AuthState) => T): AuthState | T {
const storeHook = useContext(AuthContext); const storeHook = useContext(AuthContext);
if (!storeHook) { 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(); return selector ? storeHook(selector) : storeHook();
} }
// Usage - both work! // Usage - both work!
const { user } = useAuth(); // Full state const { user } = useAuth(); // Full state
const user = useAuth(s => s.user); // Optimized selector const user = useAuth((s) => s.user); // Optimized selector
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Always call hooks internally in custom hooks** - **Always call hooks internally in custom hooks**
- Return state/values, not hook functions - Return state/values, not hook functions
- Support selectors for performance optimization - Support selectors for performance optimization
@@ -92,6 +97,7 @@ const user = useAuth(s => s.user); // Optimized selector
### Pitfall 1.2: Calling Hooks Conditionally ### Pitfall 1.2: Calling Hooks Conditionally
**❌ WRONG:** **❌ WRONG:**
```typescript ```typescript
function MyComponent({ showUser }) { function MyComponent({ showUser }) {
if (showUser) { if (showUser) {
@@ -103,6 +109,7 @@ function MyComponent({ showUser }) {
``` ```
**✅ CORRECT:** **✅ CORRECT:**
```typescript ```typescript
function MyComponent({ showUser }) { function MyComponent({ showUser }) {
const { user } = useAuth(); // ✅ Always call at top level const { user } = useAuth(); // ✅ Always call at top level
@@ -116,6 +123,7 @@ function MyComponent({ showUser }) {
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Always call hooks at the top level of your component** - **Always call hooks at the top level of your component**
- Never call hooks inside conditionals, loops, or nested functions - Never call hooks inside conditionals, loops, or nested functions
- Return early after hooks are called - Return early after hooks are called
@@ -127,6 +135,7 @@ function MyComponent({ showUser }) {
### Pitfall 2.1: Creating New Context Value on Every Render ### Pitfall 2.1: Creating New Context Value on Every Render
**❌ WRONG:** **❌ WRONG:**
```typescript ```typescript
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
@@ -139,11 +148,13 @@ export function AuthProvider({ children }) {
``` ```
**Why It's Wrong:** **Why It's Wrong:**
- Every render creates a new object - Every render creates a new object
- All consumers re-render even if values unchanged - All consumers re-render even if values unchanged
- Performance nightmare in large apps - Performance nightmare in large apps
**✅ CORRECT:** **✅ CORRECT:**
```typescript ```typescript
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
@@ -156,6 +167,7 @@ export function AuthProvider({ children }) {
``` ```
**✅ EVEN BETTER (Zustand + Context):** **✅ EVEN BETTER (Zustand + Context):**
```typescript ```typescript
export function AuthProvider({ children, store }) { export function AuthProvider({ children, store }) {
// Zustand hook function is stable (doesn't change) // Zustand hook function is stable (doesn't change)
@@ -167,6 +179,7 @@ export function AuthProvider({ children, store }) {
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Use `useMemo` for Context values that are objects** - **Use `useMemo` for Context values that are objects**
- Or use stable references (Zustand hooks, refs) - Or use stable references (Zustand hooks, refs)
- Monitor re-renders with React DevTools - Monitor re-renders with React DevTools
@@ -176,6 +189,7 @@ export function AuthProvider({ children, store }) {
### Pitfall 2.2: Prop Drilling Instead of Context ### Pitfall 2.2: Prop Drilling Instead of Context
**❌ WRONG:** **❌ WRONG:**
```typescript ```typescript
// Passing through 5 levels // Passing through 5 levels
<Layout user={user}> <Layout user={user}>
@@ -190,6 +204,7 @@ export function AuthProvider({ children, store }) {
``` ```
**✅ CORRECT:** **✅ CORRECT:**
```typescript ```typescript
// Provider at top // Provider at top
<AuthProvider> <AuthProvider>
@@ -206,6 +221,7 @@ export function AuthProvider({ children, store }) {
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Use Context for data needed by many components** - **Use Context for data needed by many components**
- Avoid prop drilling beyond 2-3 levels - Avoid prop drilling beyond 2-3 levels
- But don't overuse - local state is often better - 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 ### Pitfall 3.1: Mixing Render State Access and Mutation Logic
**❌ WRONG (Mixing patterns):** **❌ WRONG (Mixing patterns):**
```typescript ```typescript
function MyComponent() { function MyComponent() {
// Using hook for render state // Using hook for render state
@@ -231,6 +248,7 @@ function MyComponent() {
``` ```
**✅ CORRECT (Separate patterns):** **✅ CORRECT (Separate patterns):**
```typescript ```typescript
function MyComponent() { function MyComponent() {
// Hook for render state (subscribes to changes) // Hook for render state (subscribes to changes)
@@ -245,12 +263,14 @@ function MyComponent() {
``` ```
**Why This Pattern?** **Why This Pattern?**
- **Render state**: Use hook → component re-renders on changes - **Render state**: Use hook → component re-renders on changes
- **Mutations**: Use `getState()` → no subscription, no re-renders - **Mutations**: Use `getState()` → no subscription, no re-renders
- **Performance**: Event handlers don't need to subscribe - **Performance**: Event handlers don't need to subscribe
- **Clarity**: Clear distinction between read and write - **Clarity**: Clear distinction between read and write
**Key Takeaway:** **Key Takeaway:**
- **Use hooks for state that affects rendering** - **Use hooks for state that affects rendering**
- **Use `getState()` for mutations in callbacks** - **Use `getState()` for mutations in callbacks**
- Don't subscribe when you don't need to - Don't subscribe when you don't need to
@@ -260,6 +280,7 @@ function MyComponent() {
### Pitfall 3.2: Not Using Selectors for Optimization ### Pitfall 3.2: Not Using Selectors for Optimization
**❌ SUBOPTIMAL:** **❌ SUBOPTIMAL:**
```typescript ```typescript
function UserAvatar() { function UserAvatar() {
// Re-renders on ANY auth state change! ❌ // Re-renders on ANY auth state change! ❌
@@ -270,6 +291,7 @@ function UserAvatar() {
``` ```
**✅ OPTIMIZED:** **✅ OPTIMIZED:**
```typescript ```typescript
function UserAvatar() { function UserAvatar() {
// Only re-renders when user changes ✅ // Only re-renders when user changes ✅
@@ -280,6 +302,7 @@ function UserAvatar() {
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Use selectors for components that only need subset of state** - **Use selectors for components that only need subset of state**
- Reduces unnecessary re-renders - Reduces unnecessary re-renders
- Especially important in frequently updating stores - Especially important in frequently updating stores
@@ -291,13 +314,16 @@ function UserAvatar() {
### Pitfall 4.1: Using `any` Type ### Pitfall 4.1: Using `any` Type
**❌ WRONG:** **❌ WRONG:**
```typescript ```typescript
function processUser(user: any) { // ❌ Loses all type safety function processUser(user: any) {
return user.name.toUpperCase(); // No error if user.name is undefined // ❌ Loses all type safety
return user.name.toUpperCase(); // No error if user.name is undefined
} }
``` ```
**✅ CORRECT:** **✅ CORRECT:**
```typescript ```typescript
function processUser(user: User | null) { function processUser(user: User | null) {
if (!user?.name) { if (!user?.name) {
@@ -308,6 +334,7 @@ function processUser(user: User | null) {
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Never use `any` - use `unknown` if type is truly unknown** - **Never use `any` - use `unknown` if type is truly unknown**
- Define proper types for all function parameters - Define proper types for all function parameters
- Use type guards for runtime checks - Use type guards for runtime checks
@@ -317,15 +344,17 @@ function processUser(user: User | null) {
### Pitfall 4.2: Implicit Types Leading to Errors ### Pitfall 4.2: Implicit Types Leading to Errors
**❌ WRONG:** **❌ WRONG:**
```typescript ```typescript
// No explicit return type - type inference can be wrong // No explicit return type - type inference can be wrong
export function useAuth() { export function useAuth() {
const context = useContext(AuthContext); const context = useContext(AuthContext);
return context; // What type is this? ❌ return context; // What type is this? ❌
} }
``` ```
**✅ CORRECT:** **✅ CORRECT:**
```typescript ```typescript
// Explicit return type with overloads // Explicit return type with overloads
export function useAuth(): AuthState; 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 { export function useAuth<T>(selector?: (state: AuthState) => T): AuthState | T {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (!context) { 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(); return selector ? context(selector) : context();
} }
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Always provide explicit return types for public APIs** - **Always provide explicit return types for public APIs**
- Use function overloads for polymorphic functions - Use function overloads for polymorphic functions
- Document types in JSDoc comments - 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 ### Pitfall 4.3: Not Using `import type` for Type-Only Imports
**❌ SUBOPTIMAL:** **❌ SUBOPTIMAL:**
```typescript ```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:** **✅ CORRECT:**
```typescript ```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:** **Key Takeaway:**
- **Use `import type` for type-only imports** - **Use `import type` for type-only imports**
- Smaller bundle size - Smaller bundle size
- Clearer intent - 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 ### Pitfall 5.1: Forgetting Optional Chaining for Nullable Values
**❌ WRONG:** **❌ WRONG:**
```typescript ```typescript
function UserProfile() { function UserProfile() {
const { user } = useAuth(); const { user } = useAuth();
@@ -378,6 +412,7 @@ function UserProfile() {
``` ```
**✅ CORRECT:** **✅ CORRECT:**
```typescript ```typescript
function UserProfile() { function UserProfile() {
const { user } = useAuth(); const { user } = useAuth();
@@ -397,6 +432,7 @@ function UserProfile() {
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Always handle null/undefined cases** - **Always handle null/undefined cases**
- Use optional chaining (`?.`) and nullish coalescing (`??`) - Use optional chaining (`?.`) and nullish coalescing (`??`)
- Provide fallback UI for missing data - Provide fallback UI for missing data
@@ -406,6 +442,7 @@ function UserProfile() {
### Pitfall 5.2: Mixing Concerns in Components ### Pitfall 5.2: Mixing Concerns in Components
**❌ WRONG:** **❌ WRONG:**
```typescript ```typescript
function UserDashboard() { function UserDashboard() {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
@@ -429,6 +466,7 @@ function UserDashboard() {
``` ```
**✅ CORRECT:** **✅ CORRECT:**
```typescript ```typescript
// Custom hook for data fetching // Custom hook for data fetching
function useUsers() { function useUsers() {
@@ -460,6 +498,7 @@ function UserDashboard() {
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Separate concerns: data fetching, business logic, rendering** - **Separate concerns: data fetching, business logic, rendering**
- Extract logic to custom hooks - Extract logic to custom hooks
- Keep components focused on UI - Keep components focused on UI
@@ -471,6 +510,7 @@ function UserDashboard() {
### Pitfall 6.1: Wrong Provider Order ### Pitfall 6.1: Wrong Provider Order
**❌ WRONG:** **❌ WRONG:**
```typescript ```typescript
// AuthInitializer outside AuthProvider ❌ // AuthInitializer outside AuthProvider ❌
function RootLayout({ children }) { function RootLayout({ children }) {
@@ -486,6 +526,7 @@ function RootLayout({ children }) {
``` ```
**✅ CORRECT:** **✅ CORRECT:**
```typescript ```typescript
function RootLayout({ children }) { function RootLayout({ children }) {
return ( return (
@@ -500,6 +541,7 @@ function RootLayout({ children }) {
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Providers must wrap components that use them** - **Providers must wrap components that use them**
- Order matters when there are dependencies - Order matters when there are dependencies
- Keep provider tree shallow (performance) - Keep provider tree shallow (performance)
@@ -509,6 +551,7 @@ function RootLayout({ children }) {
### Pitfall 6.2: Creating Too Many Providers ### Pitfall 6.2: Creating Too Many Providers
**❌ WRONG:** **❌ WRONG:**
```typescript ```typescript
// Separate provider for every piece of state ❌ // Separate provider for every piece of state ❌
<UserProvider> <UserProvider>
@@ -525,6 +568,7 @@ function RootLayout({ children }) {
``` ```
**✅ BETTER:** **✅ BETTER:**
```typescript ```typescript
// Combine related state, use Zustand for most things // Combine related state, use Zustand for most things
<AuthProvider> {/* Only for auth DI */} <AuthProvider> {/* Only for auth DI */}
@@ -541,6 +585,7 @@ const useUserPreferences = create(...); // User settings
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Use Context only when necessary** (DI, third-party integrations) - **Use Context only when necessary** (DI, third-party integrations)
- **Use Zustand for most global state** (no provider needed) - **Use Zustand for most global state** (no provider needed)
- Avoid provider hell - Avoid provider hell
@@ -552,6 +597,7 @@ const useUserPreferences = create(...); // User settings
### Pitfall 7.1: Using Hooks in Event Handlers ### Pitfall 7.1: Using Hooks in Event Handlers
**❌ WRONG:** **❌ WRONG:**
```typescript ```typescript
function MyComponent() { function MyComponent() {
const handleClick = () => { const handleClick = () => {
@@ -564,6 +610,7 @@ function MyComponent() {
``` ```
**✅ CORRECT:** **✅ CORRECT:**
```typescript ```typescript
function MyComponent() { function MyComponent() {
const { user } = useAuth(); // ✅ Hook at component top level const { user } = useAuth(); // ✅ Hook at component top level
@@ -587,6 +634,7 @@ function MyComponent() {
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Never call hooks inside event handlers** - **Never call hooks inside event handlers**
- For render state: Call hook at top level, access in closure - For render state: Call hook at top level, access in closure
- For mutations: Use `store.getState().method()` - For mutations: Use `store.getState().method()`
@@ -596,13 +644,15 @@ function MyComponent() {
### Pitfall 7.2: Not Handling Async Errors in Event Handlers ### Pitfall 7.2: Not Handling Async Errors in Event Handlers
**❌ WRONG:** **❌ WRONG:**
```typescript ```typescript
const handleSubmit = async (data: FormData) => { const handleSubmit = async (data: FormData) => {
await apiCall(data); // ❌ No error handling! await apiCall(data); // ❌ No error handling!
}; };
``` ```
**✅ CORRECT:** **✅ CORRECT:**
```typescript ```typescript
const handleSubmit = async (data: FormData) => { const handleSubmit = async (data: FormData) => {
try { try {
@@ -616,6 +666,7 @@ const handleSubmit = async (data: FormData) => {
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Always wrap async calls in try/catch** - **Always wrap async calls in try/catch**
- Provide user feedback for both success and errors - Provide user feedback for both success and errors
- Log errors for debugging - Log errors for debugging
@@ -627,6 +678,7 @@ const handleSubmit = async (data: FormData) => {
### Pitfall 8.1: Not Mocking Context Providers in Tests ### Pitfall 8.1: Not Mocking Context Providers in Tests
**❌ WRONG:** **❌ WRONG:**
```typescript ```typescript
// Test without provider ❌ // Test without provider ❌
test('renders user name', () => { test('renders user name', () => {
@@ -636,6 +688,7 @@ test('renders user name', () => {
``` ```
**✅ CORRECT:** **✅ CORRECT:**
```typescript ```typescript
// Mock the hook // Mock the hook
jest.mock('@/lib/stores', () => ({ jest.mock('@/lib/stores', () => ({
@@ -654,6 +707,7 @@ test('renders user name', () => {
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Mock hooks at module level in tests** - **Mock hooks at module level in tests**
- Provide necessary return values for each test case - Provide necessary return values for each test case
- Test both success and error states - Test both success and error states
@@ -663,6 +717,7 @@ test('renders user name', () => {
### Pitfall 8.2: Testing Implementation Details ### Pitfall 8.2: Testing Implementation Details
**❌ WRONG:** **❌ WRONG:**
```typescript ```typescript
test('calls useAuthStore hook', () => { test('calls useAuthStore hook', () => {
const spy = jest.spyOn(require('@/lib/stores'), 'useAuthStore'); const spy = jest.spyOn(require('@/lib/stores'), 'useAuthStore');
@@ -672,6 +727,7 @@ test('calls useAuthStore hook', () => {
``` ```
**✅ CORRECT:** **✅ CORRECT:**
```typescript ```typescript
test('displays user name when authenticated', () => { test('displays user name when authenticated', () => {
(useAuth as jest.Mock).mockReturnValue({ (useAuth as jest.Mock).mockReturnValue({
@@ -685,6 +741,7 @@ test('displays user name when authenticated', () => {
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Test behavior, not implementation** - **Test behavior, not implementation**
- Focus on what the user sees/does - Focus on what the user sees/does
- Don't test internal API calls unless critical - 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 ### Pitfall 9.1: Not Using React.memo for Expensive Components
**❌ SUBOPTIMAL:** **❌ SUBOPTIMAL:**
```typescript ```typescript
// Re-renders every time parent re-renders ❌ // Re-renders every time parent re-renders ❌
function ExpensiveChart({ data }) { function ExpensiveChart({ data }) {
@@ -705,6 +763,7 @@ function ExpensiveChart({ data }) {
``` ```
**✅ OPTIMIZED:** **✅ OPTIMIZED:**
```typescript ```typescript
// Only re-renders when data changes ✅ // Only re-renders when data changes ✅
export const ExpensiveChart = React.memo(function ExpensiveChart({ data }) { export const ExpensiveChart = React.memo(function ExpensiveChart({ data }) {
@@ -713,6 +772,7 @@ export const ExpensiveChart = React.memo(function ExpensiveChart({ data }) {
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Use `React.memo` for expensive components** - **Use `React.memo` for expensive components**
- Especially useful for list items, charts, heavy UI - Especially useful for list items, charts, heavy UI
- Profile with React DevTools to identify candidates - 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 ### Pitfall 9.2: Creating Functions Inside Render
**❌ SUBOPTIMAL:** **❌ SUBOPTIMAL:**
```typescript ```typescript
function MyComponent() { function MyComponent() {
return ( return (
@@ -733,6 +794,7 @@ function MyComponent() {
``` ```
**✅ OPTIMIZED:** **✅ OPTIMIZED:**
```typescript ```typescript
function MyComponent() { function MyComponent() {
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
@@ -744,15 +806,18 @@ function MyComponent() {
``` ```
**When to Optimize:** **When to Optimize:**
- **For memoized child components** (memo, PureComponent) - **For memoized child components** (memo, PureComponent)
- **For expensive event handlers** - **For expensive event handlers**
- **When profiling shows performance issues** - **When profiling shows performance issues**
**When NOT to optimize:** **When NOT to optimize:**
- **Simple components with cheap operations** (premature optimization) - **Simple components with cheap operations** (premature optimization)
- **One-off event handlers** - **One-off event handlers**
**Key Takeaway:** **Key Takeaway:**
- **Use `useCallback` for functions passed to memoized children** - **Use `useCallback` for functions passed to memoized children**
- But don't optimize everything - profile first - But don't optimize everything - profile first
@@ -763,6 +828,7 @@ function MyComponent() {
### Pitfall 10.1: Not Using Barrel Exports ### Pitfall 10.1: Not Using Barrel Exports
**❌ INCONSISTENT:** **❌ INCONSISTENT:**
```typescript ```typescript
// Deep imports all over the codebase // Deep imports all over the codebase
import { useAuth } from '@/lib/auth/AuthContext'; import { useAuth } from '@/lib/auth/AuthContext';
@@ -771,6 +837,7 @@ import { User } from '@/lib/stores/authStore';
``` ```
**✅ CONSISTENT:** **✅ CONSISTENT:**
```typescript ```typescript
// Barrel exports in stores/index.ts // Barrel exports in stores/index.ts
export { useAuth, AuthProvider } from '../auth/AuthContext'; export { useAuth, AuthProvider } from '../auth/AuthContext';
@@ -781,6 +848,7 @@ import { useAuth, useAuthStore, User } from '@/lib/stores';
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Create barrel exports (index.ts) for public APIs** - **Create barrel exports (index.ts) for public APIs**
- Easier to refactor internal structure - Easier to refactor internal structure
- Consistent import paths across codebase - Consistent import paths across codebase
@@ -790,31 +858,44 @@ import { useAuth, useAuthStore, User } from '@/lib/stores';
### Pitfall 10.2: Circular Dependencies ### Pitfall 10.2: Circular Dependencies
**❌ WRONG:** **❌ WRONG:**
```typescript ```typescript
// fileA.ts // fileA.ts
import { functionB } from './fileB'; import { functionB } from './fileB';
export function functionA() { return functionB(); } export function functionA() {
return functionB();
}
// fileB.ts // fileB.ts
import { functionA } from './fileA'; // ❌ Circular! import { functionA } from './fileA'; // ❌ Circular!
export function functionB() { return functionA(); } export function functionB() {
return functionA();
}
``` ```
**✅ CORRECT:** **✅ CORRECT:**
```typescript ```typescript
// utils.ts // utils.ts
export function sharedFunction() { /* shared logic */ } export function sharedFunction() {
/* shared logic */
}
// fileA.ts // fileA.ts
import { sharedFunction } from './utils'; import { sharedFunction } from './utils';
export function functionA() { return sharedFunction(); } export function functionA() {
return sharedFunction();
}
// fileB.ts // fileB.ts
import { sharedFunction } from './utils'; import { sharedFunction } from './utils';
export function functionB() { return sharedFunction(); } export function functionB() {
return sharedFunction();
}
``` ```
**Key Takeaway:** **Key Takeaway:**
- **Avoid circular imports** - **Avoid circular imports**
- Extract shared code to separate modules - Extract shared code to separate modules
- Keep dependency graph acyclic - Keep dependency graph acyclic
@@ -840,6 +921,7 @@ npm run build
``` ```
**In browser:** **In browser:**
- [ ] No console errors or warnings - [ ] No console errors or warnings
- [ ] Components render correctly - [ ] Components render correctly
- [ ] No infinite loops or excessive re-renders (React DevTools) - [ ] No infinite loops or excessive re-renders (React DevTools)

View File

@@ -93,12 +93,14 @@ npm run coverage:view
### Pros & Cons ### Pros & Cons
**Pros:** **Pros:**
- ✅ Native browser coverage (most accurate) - ✅ Native browser coverage (most accurate)
- ✅ No build instrumentation needed (faster) - ✅ No build instrumentation needed (faster)
- ✅ Works with source maps - ✅ Works with source maps
- ✅ Zero performance overhead - ✅ Zero performance overhead
**Cons:** **Cons:**
- ❌ Chromium only (V8 engine specific) - ❌ Chromium only (V8 engine specific)
- ❌ Requires v8-to-istanbul conversion - ❌ Requires v8-to-istanbul conversion
@@ -168,11 +170,13 @@ This generates: `coverage-combined/index.html`
### Pros & Cons ### Pros & Cons
**Pros:** **Pros:**
- ✅ Works on all browsers (Firefox, Safari, etc.) - ✅ Works on all browsers (Firefox, Safari, etc.)
- ✅ Industry standard tooling - ✅ Industry standard tooling
- ✅ No conversion needed - ✅ No conversion needed
**Cons:** **Cons:**
- ❌ Requires code instrumentation (slower builds) - ❌ Requires code instrumentation (slower builds)
- ❌ More complex setup - ❌ More complex setup
- ❌ Slight test performance overhead - ❌ Slight test performance overhead
@@ -196,11 +200,9 @@ module.exports = {
presets: ['next/babel'], presets: ['next/babel'],
env: { env: {
test: { test: {
plugins: [ plugins: [process.env.E2E_COVERAGE && 'istanbul'].filter(Boolean),
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 ### Problem: No coverage files generated
**Symptoms:** **Symptoms:**
```bash ```bash
npm run coverage:convert npm run coverage:convert
# ❌ No V8 coverage found at: coverage-e2e/raw # ❌ No V8 coverage found at: coverage-e2e/raw
``` ```
**Solutions:** **Solutions:**
1. Verify `E2E_COVERAGE=true` is set when running tests 1. Verify `E2E_COVERAGE=true` is set when running tests
2. Check coverage helpers are imported: `import { withCoverage } from './helpers/coverage'` 2. Check coverage helpers are imported: `import { withCoverage } from './helpers/coverage'`
3. Verify `beforeEach` and `afterEach` hooks are added 3. Verify `beforeEach` and `afterEach` hooks are added
@@ -456,12 +460,14 @@ npm run coverage:convert
### Problem: V8 conversion fails ### Problem: V8 conversion fails
**Symptoms:** **Symptoms:**
```bash ```bash
npm run coverage:convert npm run coverage:convert
# ❌ v8-to-istanbul not installed # ❌ v8-to-istanbul not installed
``` ```
**Solution:** **Solution:**
```bash ```bash
npm install -D v8-to-istanbul npm install -D v8-to-istanbul
``` ```
@@ -469,6 +475,7 @@ npm install -D v8-to-istanbul
### Problem: Coverage lower than expected ### Problem: Coverage lower than expected
**Symptoms:** **Symptoms:**
``` ```
Combined: 85% (expected 99%) Combined: 85% (expected 99%)
``` ```
@@ -490,12 +497,14 @@ Combined: 85% (expected 99%)
### Problem: Istanbul coverage is empty ### Problem: Istanbul coverage is empty
**Symptoms:** **Symptoms:**
```typescript ```typescript
await saveIstanbulCoverage(page, testName); await saveIstanbulCoverage(page, testName);
// ⚠️ No Istanbul coverage found // ⚠️ No Istanbul coverage found
``` ```
**Solutions:** **Solutions:**
1. Verify `babel-plugin-istanbul` is configured 1. Verify `babel-plugin-istanbul` is configured
2. Check `window.__coverage__` exists: 2. Check `window.__coverage__` exists:
```typescript ```typescript
@@ -507,12 +516,14 @@ await saveIstanbulCoverage(page, testName);
### Problem: Merge script fails ### Problem: Merge script fails
**Symptoms:** **Symptoms:**
```bash ```bash
npm run coverage:merge npm run coverage:merge
# ❌ Error: Cannot find module 'istanbul-lib-coverage' # ❌ Error: Cannot find module 'istanbul-lib-coverage'
``` ```
**Solution:** **Solution:**
```bash ```bash
npm install -D istanbul-lib-coverage istanbul-lib-report istanbul-reports 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? ### Q: Should I use V8 or Istanbul coverage?
**A: V8 coverage (Approach 1)** if: **A: V8 coverage (Approach 1)** if:
- ✅ You only test in Chromium - ✅ You only test in Chromium
- ✅ You want zero instrumentation overhead - ✅ You want zero instrumentation overhead
- ✅ You want the most accurate coverage - ✅ You want the most accurate coverage
**Istanbul (Approach 2)** if: **Istanbul (Approach 2)** if:
- ✅ You need cross-browser coverage - ✅ You need cross-browser coverage
- ✅ You already use Istanbul tooling - ✅ You already use Istanbul tooling
- ✅ You need complex coverage transformations - ✅ 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? ### Q: Can I run coverage only for specific tests?
**Yes:** **Yes:**
```bash ```bash
# Single file # Single file
E2E_COVERAGE=true npx playwright test homepage.spec.ts E2E_COVERAGE=true npx playwright test homepage.spec.ts
@@ -559,10 +573,7 @@ Edit `.nycrc.json` and add to `exclude` array:
```json ```json
{ {
"exclude": [ "exclude": ["src/app/dev/**", "src/lib/utils/debug.ts"]
"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: Not directly in the HTML report, but you can:
1. Generate separate reports: 1. Generate separate reports:
```bash ```bash
npx nyc report --reporter=html --report-dir=coverage-unit --temp-dir=coverage/.nyc_output 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 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? ### Q: What's the performance impact on CI?
Typical impact: Typical impact:
- V8 coverage: +2-3 minutes (conversion time) - V8 coverage: +2-3 minutes (conversion time)
- Istanbul coverage: +5-7 minutes (build instrumentation) - Istanbul coverage: +5-7 minutes (build instrumentation)
- Merge step: ~10 seconds - Merge step: ~10 seconds
@@ -594,6 +607,7 @@ Total CI time increase: **3-8 minutes**
### After Phase 1 (Infrastructure - DONE ✅) ### After Phase 1 (Infrastructure - DONE ✅)
You've completed: You've completed:
- ✅ `.nycrc.json` configuration - ✅ `.nycrc.json` configuration
- ✅ Merge script (`scripts/merge-coverage.ts`) - ✅ Merge script (`scripts/merge-coverage.ts`)
- ✅ Conversion script (`scripts/convert-v8-to-istanbul.ts`) - ✅ Conversion script (`scripts/convert-v8-to-istanbul.ts`)
@@ -603,6 +617,7 @@ You've completed:
### Phase 2: Activation (When Ready) ### Phase 2: Activation (When Ready)
1. **Install dependencies:** 1. **Install dependencies:**
```bash ```bash
npm install -D v8-to-istanbul istanbul-lib-coverage istanbul-lib-report istanbul-reports npm install -D v8-to-istanbul istanbul-lib-coverage istanbul-lib-report istanbul-reports
``` ```

View File

@@ -6,20 +6,24 @@
## Bottleneck Analysis ## Bottleneck Analysis
### 1. Authentication Overhead (HIGHEST IMPACT) ### 1. Authentication Overhead (HIGHEST IMPACT)
**Problem**: Each test logs in fresh via UI **Problem**: Each test logs in fresh via UI
- **Impact**: 5-7s per test × 133 admin tests = ~700s wasted - **Impact**: 5-7s per test × 133 admin tests = ~700s wasted
- **Root Cause**: Using `loginViaUI(page)` in every `beforeEach` - **Root Cause**: Using `loginViaUI(page)` in every `beforeEach`
**Example of current slow pattern:** **Example of current slow pattern:**
```typescript ```typescript
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await setupSuperuserMocks(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'); await page.goto('/admin');
}); });
``` ```
**Solution: Playwright Storage State** (SAVE ~600-700s) **Solution: Playwright Storage State** (SAVE ~600-700s)
```typescript ```typescript
// auth.setup.ts - Run ONCE per worker // auth.setup.ts - Run ONCE per worker
import { test as setup } from '@playwright/test'; import { test as setup } from '@playwright/test';
@@ -62,7 +66,7 @@ export default defineConfig({
// admin-users.spec.ts - NO MORE loginViaUI! // admin-users.spec.ts - NO MORE loginViaUI!
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
// Auth already loaded from storageState // 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) ### 2. Redundant Navigation Tests (MEDIUM IMPACT)
**Problem**: Separate tests for "navigate to X" and "display X page" **Problem**: Separate tests for "navigate to X" and "display X page"
- **Impact**: 3-5s per redundant test × ~15 tests = ~60s wasted - **Impact**: 3-5s per redundant test × ~15 tests = ~60s wasted
**Current slow pattern:** **Current slow pattern:**
```typescript ```typescript
test('should navigate to users page', async ({ page }) => { 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).toHaveURL('/admin/users');
await expect(page.locator('h1')).toContainText('User Management'); await expect(page.locator('h1')).toContainText('User Management');
}); });
test('should display user management page', async ({ page }) => { 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.locator('h1')).toContainText('User Management');
await expect(page.getByText(/manage users/i)).toBeVisible(); await expect(page.getByText(/manage users/i)).toBeVisible();
}); });
``` ```
**Optimized pattern:** **Optimized pattern:**
```typescript ```typescript
test('should navigate to users page and display content', async ({ page }) => { 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 // Navigation assertions
await expect(page).toHaveURL('/admin/users'); 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) ### 3. Flaky Test Fix (CRITICAL)
**Problem**: Test #218 failed once, passed on retry **Problem**: Test #218 failed once, passed on retry
``` ```
Test: settings-password.spec.ts:24:7 Password Change should display password change form Test: settings-password.spec.ts:24:7 Password Change should display password change form
Failed: 12.8s → Retry passed: 8.3s Failed: 12.8s → Retry passed: 8.3s
``` ```
**Root Cause Options**: **Root Cause Options**:
1. Race condition in form rendering 1. Race condition in form rendering
2. Slow network request not properly awaited 2. Slow network request not properly awaited
3. Animation/transition timing issue 3. Animation/transition timing issue
**Investigation needed:** **Investigation needed:**
```typescript ```typescript
// Current test (lines 24-35) // Current test (lines 24-35)
test('should display password change form', async ({ page }) => { 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** **Temporary Solution: Skip until fixed**
```typescript ```typescript
test.skip('should display password change form', async ({ page }) => { test.skip('should display password change form', async ({ page }) => {
// TODO: Fix race condition (issue #XXX) // TODO: Fix race condition (issue #XXX)
await page.goto('/settings/password'); 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(); 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) ### 4. Optimize Wait Timeouts (LOW IMPACT)
**Problem**: Default timeout is 10s for all assertions **Problem**: Default timeout is 10s for all assertions
- **Impact**: Tests wait unnecessarily when elements load faster - **Impact**: Tests wait unnecessarily when elements load faster
**Current global timeout:** **Current global timeout:**
```typescript ```typescript
// playwright.config.ts // playwright.config.ts
export default defineConfig({ export default defineConfig({
timeout: 30000, // Per test timeout: 30000, // Per test
expect: { timeout: 10000 }, // Per assertion expect: { timeout: 10000 }, // Per assertion
}); });
``` ```
**Optimized for fast-loading pages:** **Optimized for fast-loading pages:**
```typescript ```typescript
export default defineConfig({ export default defineConfig({
timeout: 20000, // Reduce from 30s timeout: 20000, // Reduce from 30s
expect: { timeout: 5000 }, // Reduce from 10s (most elements load <2s) expect: { timeout: 5000 }, // Reduce from 10s (most elements load <2s)
}); });
``` ```
@@ -175,6 +192,7 @@ export default defineConfig({
## Implementation Priority ## Implementation Priority
### Phase 1: Quick Wins (1-2 hours work) ### Phase 1: Quick Wins (1-2 hours work)
1.**Skip flaky test #218** temporarily 1.**Skip flaky test #218** temporarily
2.**Reduce timeout defaults** (5s for expects, 20s for tests) 2.**Reduce timeout defaults** (5s for expects, 20s for tests)
3.**Combine 5 most obvious redundant navigation 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) ### Phase 2: Auth State Caching (2-4 hours work)
1. ✅ Create `e2e/auth.setup.ts` with storage state setup 1. ✅ Create `e2e/auth.setup.ts` with storage state setup
2. ✅ Update `playwright.config.ts` with projects + dependencies 2. ✅ Update `playwright.config.ts` with projects + dependencies
3. ✅ Remove `loginViaUI` from all admin test `beforeEach` hooks 3. ✅ Remove `loginViaUI` from all admin test `beforeEach` hooks
@@ -194,6 +213,7 @@ export default defineConfig({
--- ---
### Phase 3: Deep Optimization (4-8 hours work) ### Phase 3: Deep Optimization (4-8 hours work)
1. ✅ Investigate and fix flaky test root cause 1. ✅ Investigate and fix flaky test root cause
2. ✅ Audit all navigation tests for redundancy 2. ✅ Audit all navigation tests for redundancy
3. ✅ Combine related assertions (e.g., all stat cards in one test) 3. ✅ Combine related assertions (e.g., all stat cards in one test)
@@ -205,12 +225,12 @@ export default defineConfig({
## Total Expected Improvement ## Total Expected Improvement
| Phase | Time Investment | Time Saved | % Improvement | | Phase | Time Investment | Time Saved | % Improvement |
|-------|----------------|------------|---------------| | --------- | --------------- | ---------- | ------------- |
| Phase 1 | 1-2 hours | ~150s | 7% | | Phase 1 | 1-2 hours | ~150s | 7% |
| Phase 2 | 2-4 hours | ~700s | 35% | | Phase 2 | 2-4 hours | ~700s | 35% |
| Phase 3 | 4-8 hours | ~200s | 10% | | Phase 3 | 4-8 hours | ~200s | 10% |
| **Total** | **7-14 hours** | **~1050s** | **50-60%** | | **Total** | **7-14 hours** | **~1050s** | **50-60%** |
**Final target**: 2100s → 1050s = **~17-18 minutes** (currently ~35 minutes) **Final target**: 2100s → 1050s = **~17-18 minutes** (currently ~35 minutes)
@@ -219,6 +239,7 @@ export default defineConfig({
## Risks and Considerations ## Risks and Considerations
### Storage State Caching Risks: ### Storage State Caching Risks:
1. **Test isolation**: Shared auth state could cause cross-test pollution 1. **Test isolation**: Shared auth state could cause cross-test pollution
- **Mitigation**: Use separate storage files per role, clear cookies between tests - **Mitigation**: Use separate storage files per role, clear cookies between tests
2. **Stale auth tokens**: Mock tokens might expire 2. **Stale auth tokens**: Mock tokens might expire
@@ -227,6 +248,7 @@ export default defineConfig({
- **Mitigation**: Keep `loginViaUI` tests for auth flow verification - **Mitigation**: Keep `loginViaUI` tests for auth flow verification
### Recommended Safeguards: ### Recommended Safeguards:
```typescript ```typescript
// Clear non-auth state between tests // Clear non-auth state between tests
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@@ -249,15 +271,18 @@ test.beforeEach(async ({ page }) => {
## Next Steps ## Next Steps
**Immediate Actions (Do Now):** **Immediate Actions (Do Now):**
1. Skip flaky test #218 with TODO comment 1. Skip flaky test #218 with TODO comment
2. Reduce timeout defaults in playwright.config.ts 2. Reduce timeout defaults in playwright.config.ts
3. Create this optimization plan issue/ticket 3. Create this optimization plan issue/ticket
**Short-term (This Week):** **Short-term (This Week):**
1. Implement auth storage state (Phase 2) 1. Implement auth storage state (Phase 2)
2. Combine obvious redundant tests (Phase 1) 2. Combine obvious redundant tests (Phase 1)
**Medium-term (Next Sprint):** **Medium-term (Next Sprint):**
1. Investigate flaky test root cause 1. Investigate flaky test root cause
2. Audit all tests for redundancy 2. Audit all tests for redundancy
3. Measure and report improvements 3. Measure and report improvements
@@ -267,18 +292,21 @@ test.beforeEach(async ({ page }) => {
## Metrics to Track ## Metrics to Track
Before optimization: Before optimization:
- Total time: ~2100s (35 minutes) - Total time: ~2100s (35 minutes)
- Avg test time: 9.1s - Avg test time: 9.1s
- Slowest test: 20.1s (settings navigation) - Slowest test: 20.1s (settings navigation)
- Flaky tests: 1 - Flaky tests: 1
After Phase 1+2 target: After Phase 1+2 target:
- Total time: <1200s (20 minutes) ✅ - Total time: <1200s (20 minutes) ✅
- Avg test time: <5.5s ✅ - Avg test time: <5.5s ✅
- Slowest test: <12s ✅ - Slowest test: <12s ✅
- Flaky tests: 0 ✅ - Flaky tests: 0 ✅
After Phase 3 target: After Phase 3 target:
- Total time: <1050s (17 minutes) 🎯 - Total time: <1050s (17 minutes) 🎯
- Avg test time: <4.8s 🎯 - Avg test time: <4.8s 🎯
- Slowest test: <10s 🎯 - Slowest test: <10s 🎯

View File

@@ -69,8 +69,7 @@ export function useCreateUser() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (data: CreateUserDto) => mutationFn: (data: CreateUserDto) => AdminService.createUser({ requestBody: data }),
AdminService.createUser({ requestBody: data }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success('User created successfully'); toast.success('User created successfully');
@@ -106,8 +105,7 @@ export function useDeleteUser() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (userId: string) => mutationFn: (userId: string) => AdminService.deleteUser({ userId }),
AdminService.deleteUser({ userId }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success('User deleted successfully'); toast.success('User deleted successfully');
@@ -145,7 +143,7 @@ export function useBulkUserAction() {
return useMutation({ return useMutation({
mutationFn: ({ action, userIds }: { action: string; userIds: string[] }) => mutationFn: ({ action, userIds }: { action: string; userIds: string[] }) =>
AdminService.bulkUserAction({ AdminService.bulkUserAction({
requestBody: { action, user_ids: userIds } requestBody: { action, user_ids: userIds },
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['users'] });
@@ -592,6 +590,7 @@ export default function NewUserPage() {
### Testing the Feature ### Testing the Feature
**Test the user management flow:** **Test the user management flow:**
1. Navigate to `/admin/users` 1. Navigate to `/admin/users`
2. Search for users 2. Search for users
3. Click "Create User" and fill the form 3. Click "Create User" and fill the form
@@ -630,8 +629,7 @@ export function useRevokeSession() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (sessionId: string) => mutationFn: (sessionId: string) => SessionsService.revokeSession({ sessionId }),
SessionsService.revokeSession({ sessionId }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sessions'] }); queryClient.invalidateQueries({ queryKey: ['sessions'] });
toast.success('Session revoked successfully'); toast.success('Session revoked successfully');
@@ -1041,11 +1039,13 @@ export default function AdminDashboardPage() {
## Conclusion ## Conclusion
These examples demonstrate: These examples demonstrate:
1. **Complete CRUD operations** (User Management) 1. **Complete CRUD operations** (User Management)
2. **Real-time data with polling** (Session Management) 2. **Real-time data with polling** (Session Management)
3. **Data visualization** (Admin Dashboard Charts) 3. **Data visualization** (Admin Dashboard Charts)
Each example follows the established patterns: Each example follows the established patterns:
- API hooks for data fetching - API hooks for data fetching
- Reusable components - Reusable components
- Proper error handling - Proper error handling
@@ -1053,6 +1053,7 @@ Each example follows the established patterns:
- Type safety with TypeScript - Type safety with TypeScript
For more patterns and best practices, refer to: For more patterns and best practices, refer to:
- **ARCHITECTURE.md**: System design - **ARCHITECTURE.md**: System design
- **CODING_STANDARDS.md**: Code style - **CODING_STANDARDS.md**: Code style
- **COMPONENT_GUIDE.md**: Component usage - **COMPONENT_GUIDE.md**: Component usage

View File

@@ -66,7 +66,7 @@ import {
CardTitle, CardTitle,
CardDescription, CardDescription,
CardContent, CardContent,
CardFooter CardFooter,
} from '@/components/ui/card'; } from '@/components/ui/card';
<Card> <Card>
@@ -80,7 +80,7 @@ import {
<CardFooter> <CardFooter>
<Button>Save</Button> <Button>Save</Button>
</CardFooter> </CardFooter>
</Card> </Card>;
``` ```
**[See card examples](/dev/components#card)** **[See card examples](/dev/components#card)**
@@ -95,18 +95,9 @@ import { Input } from '@/components/ui/input';
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input <Input id="email" type="email" placeholder="you@example.com" {...register('email')} />
id="email" {errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
type="email" </div>;
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)** **[See form patterns](./06-forms.md)** | **[Form examples](/dev/forms)**
@@ -123,7 +114,7 @@ import {
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogTrigger DialogTrigger,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
<Dialog> <Dialog>
@@ -133,16 +124,14 @@ import {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Confirm Action</DialogTitle> <DialogTitle>Confirm Action</DialogTitle>
<DialogDescription> <DialogDescription>Are you sure you want to proceed?</DialogDescription>
Are you sure you want to proceed?
</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline">Cancel</Button> <Button variant="outline">Cancel</Button>
<Button>Confirm</Button> <Button>Confirm</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>;
``` ```
**[See dialog examples](/dev/components#dialog)** **[See dialog examples](/dev/components#dialog)**
@@ -197,7 +186,7 @@ import { AlertCircle } from 'lucide-react';
```tsx ```tsx
// Responsive card grid // Responsive card grid
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <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}> <Card key={item.id}>
<CardHeader> <CardHeader>
<CardTitle>{item.title}</CardTitle> <CardTitle>{item.title}</CardTitle>
@@ -218,9 +207,7 @@ import { AlertCircle } from 'lucide-react';
<CardTitle>Login</CardTitle> <CardTitle>Login</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form className="space-y-4"> <form className="space-y-4">{/* Form fields */}</form>
{/* Form fields */}
</form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -247,6 +234,7 @@ import { AlertCircle } from 'lucide-react';
``` ```
**Available tokens:** **Available tokens:**
- `primary` - Main brand color, CTAs - `primary` - Main brand color, CTAs
- `destructive` - Errors, delete actions - `destructive` - Errors, delete actions
- `muted` - Disabled states, subtle backgrounds - `muted` - Disabled states, subtle backgrounds
@@ -276,6 +264,7 @@ import { AlertCircle } from 'lucide-react';
``` ```
**Common spacing values:** **Common spacing values:**
- `2` (8px) - Tight spacing - `2` (8px) - Tight spacing
- `4` (16px) - Standard spacing - `4` (16px) - Standard spacing
- `6` (24px) - Section spacing - `6` (24px) - Section spacing
@@ -326,6 +315,7 @@ import { AlertCircle } from 'lucide-react';
``` ```
**Breakpoints:** **Breakpoints:**
- `sm:` 640px+ - `sm:` 640px+
- `md:` 768px+ - `md:` 768px+
- `lg:` 1024px+ - `lg:` 1024px+
@@ -370,11 +360,9 @@ import { AlertCircle } from 'lucide-react';
```tsx ```tsx
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
{isLoading ? ( {
<Skeleton className="h-12 w-full" /> isLoading ? <Skeleton className="h-12 w-full" /> : <div>{content}</div>;
) : ( }
<div>{content}</div>
)}
``` ```
### Dropdown Menu ### Dropdown Menu
@@ -384,7 +372,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
<DropdownMenu> <DropdownMenu>
@@ -395,7 +383,7 @@ import {
<DropdownMenuItem>Edit</DropdownMenuItem> <DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Delete</DropdownMenuItem> <DropdownMenuItem>Delete</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>;
``` ```
### Badge/Tag ### Badge/Tag
@@ -415,17 +403,20 @@ import { Badge } from '@/components/ui/badge';
You now know enough to build most interfaces! For deeper knowledge: You now know enough to build most interfaces! For deeper knowledge:
### Learn More ### Learn More
- **Components**: [Complete component guide](./02-components.md) - **Components**: [Complete component guide](./02-components.md)
- **Layouts**: [Layout patterns](./03-layouts.md) - **Layouts**: [Layout patterns](./03-layouts.md)
- **Forms**: [Form patterns & validation](./06-forms.md) - **Forms**: [Form patterns & validation](./06-forms.md)
- **Custom Components**: [Component creation guide](./05-component-creation.md) - **Custom Components**: [Component creation guide](./05-component-creation.md)
### Interactive Examples ### Interactive Examples
- **[Component Showcase](/dev/components)** - All components with code - **[Component Showcase](/dev/components)** - All components with code
- **[Layout Examples](/dev/layouts)** - Before/after comparisons - **[Layout Examples](/dev/layouts)** - Before/after comparisons
- **[Form Examples](/dev/forms)** - Complete form implementations - **[Form Examples](/dev/forms)** - Complete form implementations
### Reference ### Reference
- **[Quick Reference Tables](./99-reference.md)** - Bookmark this for lookups - **[Quick Reference Tables](./99-reference.md)** - Bookmark this for lookups
- **[Foundations](./01-foundations.md)** - Complete color/spacing/typography guide - **[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). You're ready to build. When you hit edge cases or need advanced patterns, refer back to the [full documentation](./README.md).
**Bookmark these:** **Bookmark these:**
- [Quick Reference](./99-reference.md) - For quick lookups - [Quick Reference](./99-reference.md) - For quick lookups
- [AI Guidelines](./08-ai-guidelines.md) - If using AI assistants - [AI Guidelines](./08-ai-guidelines.md) - If using AI assistants
- [Component Showcase](/dev/components) - For copy-paste examples - [Component Showcase](/dev/components) - For copy-paste examples

View File

@@ -43,6 +43,7 @@
### Why OKLCH? ### Why OKLCH?
We use **OKLCH** (Oklab LCH) color space for: We use **OKLCH** (Oklab LCH) color space for:
-**Perceptual uniformity** - Colors look consistent across light/dark modes -**Perceptual uniformity** - Colors look consistent across light/dark modes
-**Better accessibility** - Predictable contrast ratios -**Better accessibility** - Predictable contrast ratios
-**Vibrant colors** - More saturated without sacrificing legibility -**Vibrant colors** - More saturated without sacrificing legibility
@@ -55,6 +56,7 @@ We use **OKLCH** (Oklab LCH) color space for:
### Semantic Color Tokens ### Semantic Color Tokens
All colors follow the **background/foreground** convention: All colors follow the **background/foreground** convention:
- `background` - The background color - `background` - The background color
- `foreground` - The text color that goes on that background - `foreground` - The text color that goes on that background
@@ -68,11 +70,12 @@ All colors follow the **background/foreground** convention:
```css ```css
/* Light & Dark Mode */ /* Light & Dark Mode */
--primary: oklch(0.6231 0.1880 259.8145) /* Blue */ --primary: oklch(0.6231 0.188 259.8145) /* Blue */ --primary-foreground: oklch(1 0 0)
--primary-foreground: oklch(1 0 0) /* White text */ /* White text */;
``` ```
**Usage**: **Usage**:
```tsx ```tsx
// Primary button (most common) // Primary button (most common)
<Button>Save Changes</Button> <Button>Save Changes</Button>
@@ -87,12 +90,14 @@ All colors follow the **background/foreground** convention:
``` ```
**When to use**: **When to use**:
- ✅ Call-to-action buttons - ✅ Call-to-action buttons
- ✅ Primary links - ✅ Primary links
- ✅ Active states in navigation - ✅ Active states in navigation
- ✅ Important badges/tags - ✅ Important badges/tags
**When NOT to use**: **When NOT to use**:
- ❌ Large background areas (too intense) - ❌ Large background areas (too intense)
- ❌ Body text (use `text-foreground`) - ❌ Body text (use `text-foreground`)
- ❌ Disabled states (use `muted`) - ❌ Disabled states (use `muted`)
@@ -105,15 +110,14 @@ All colors follow the **background/foreground** convention:
```css ```css
/* Light Mode */ /* Light Mode */
--secondary: oklch(0.9670 0.0029 264.5419) /* Light gray-blue */ --secondary: oklch(0.967 0.0029 264.5419) /* Light gray-blue */
--secondary-foreground: oklch(0.1529 0 0) /* Dark text */ --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)
/* Dark Mode */ /* Light text */;
--secondary: oklch(0.2686 0 0) /* Dark gray */
--secondary-foreground: oklch(0.9823 0 0) /* Light text */
``` ```
**Usage**: **Usage**:
```tsx ```tsx
// Secondary button // Secondary button
<Button variant="secondary">Cancel</Button> <Button variant="secondary">Cancel</Button>
@@ -135,15 +139,12 @@ All colors follow the **background/foreground** convention:
```css ```css
/* Light Mode */ /* Light Mode */
--muted: oklch(0.9846 0.0017 247.8389) --muted: oklch(0.9846 0.0017 247.8389) --muted-foreground: oklch(0.4667 0.0043 264.4327)
--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);
/* Dark Mode */
--muted: oklch(0.2393 0 0)
--muted-foreground: oklch(0.6588 0.0043 264.4327)
``` ```
**Usage**: **Usage**:
```tsx ```tsx
// Disabled button // Disabled button
<Button disabled>Submit</Button> <Button disabled>Submit</Button>
@@ -165,6 +166,7 @@ All colors follow the **background/foreground** convention:
``` ```
**Common use cases**: **Common use cases**:
- Disabled button backgrounds - Disabled button backgrounds
- Placeholder/skeleton loaders - Placeholder/skeleton loaders
- TabsList backgrounds - TabsList backgrounds
@@ -179,15 +181,12 @@ All colors follow the **background/foreground** convention:
```css ```css
/* Light Mode */ /* Light Mode */
--accent: oklch(0.9514 0.0250 236.8242) --accent: oklch(0.9514 0.025 236.8242) --accent-foreground: oklch(0.1529 0 0) /* Dark Mode */
--accent-foreground: oklch(0.1529 0 0) --accent: oklch(0.3791 0.1378 265.5222) --accent-foreground: oklch(0.9823 0 0);
/* Dark Mode */
--accent: oklch(0.3791 0.1378 265.5222)
--accent-foreground: oklch(0.9823 0 0)
``` ```
**Usage**: **Usage**:
```tsx ```tsx
// Dropdown menu item hover // Dropdown menu item hover
<DropdownMenu> <DropdownMenu>
@@ -205,6 +204,7 @@ All colors follow the **background/foreground** convention:
``` ```
**Common use cases**: **Common use cases**:
- Dropdown menu item hover states - Dropdown menu item hover states
- Command palette hover states - Command palette hover states
- Highlighted sections - Highlighted sections
@@ -218,11 +218,12 @@ All colors follow the **background/foreground** convention:
```css ```css
/* Light & Dark Mode */ /* Light & Dark Mode */
--destructive: oklch(0.6368 0.2078 25.3313) /* Red */ --destructive: oklch(0.6368 0.2078 25.3313) /* Red */ --destructive-foreground: oklch(1 0 0)
--destructive-foreground: oklch(1 0 0) /* White text */ /* White text */;
``` ```
**Usage**: **Usage**:
```tsx ```tsx
// Delete button // Delete button
<Button variant="destructive">Delete Account</Button> <Button variant="destructive">Delete Account</Button>
@@ -246,6 +247,7 @@ All colors follow the **background/foreground** convention:
``` ```
**When to use**: **When to use**:
- ✅ Delete/remove actions - ✅ Delete/remove actions
- ✅ Error messages - ✅ Error messages
- ✅ Validation errors - ✅ Validation errors
@@ -259,19 +261,15 @@ All colors follow the **background/foreground** convention:
```css ```css
/* Light Mode */ /* Light Mode */
--card: oklch(1.0000 0 0) /* White */ --card: oklch(1 0 0) /* White */ --card-foreground: oklch(0.1529 0 0) /* Dark text */
--card-foreground: oklch(0.1529 0 0) /* Dark text */ --popover: oklch(1 0 0) /* White */ --popover-foreground: oklch(0.1529 0 0) /* Dark text */
--popover: oklch(1.0000 0 0) /* White */ /* Dark Mode */ --card: oklch(0.2686 0 0) /* Dark gray */ --card-foreground: oklch(0.9823 0 0)
--popover-foreground: oklch(0.1529 0 0) /* Dark text */ /* Light text */ --popover: oklch(0.2686 0 0) /* Dark gray */
--popover-foreground: oklch(0.9823 0 0) /* Light 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**: **Usage**:
```tsx ```tsx
// Card (uses card colors by default) // Card (uses card colors by default)
<Card> <Card>
@@ -296,15 +294,12 @@ All colors follow the **background/foreground** convention:
```css ```css
/* Light Mode */ /* Light Mode */
--border: oklch(0.9276 0.0058 264.5313) --border: oklch(0.9276 0.0058 264.5313) --input: oklch(0.9276 0.0058 264.5313) /* Dark Mode */
--input: oklch(0.9276 0.0058 264.5313) --border: oklch(0.3715 0 0) --input: oklch(0.3715 0 0);
/* Dark Mode */
--border: oklch(0.3715 0 0)
--input: oklch(0.3715 0 0)
``` ```
**Usage**: **Usage**:
```tsx ```tsx
// Input border // Input border
<Input type="email" placeholder="you@example.com" /> <Input type="email" placeholder="you@example.com" />
@@ -329,10 +324,11 @@ All colors follow the **background/foreground** convention:
```css ```css
/* Light & Dark Mode */ /* Light & Dark Mode */
--ring: oklch(0.6231 0.1880 259.8145) /* Primary blue */ --ring: oklch(0.6231 0.188 259.8145) /* Primary blue */;
``` ```
**Usage**: **Usage**:
```tsx ```tsx
// Button with focus ring (automatic) // Button with focus ring (automatic)
<Button>Click me</Button> <Button>Click me</Button>
@@ -355,14 +351,14 @@ All colors follow the **background/foreground** convention:
**Purpose**: Data visualization with harmonious color palette **Purpose**: Data visualization with harmonious color palette
```css ```css
--chart-1: oklch(0.6231 0.1880 259.8145) /* Blue */ --chart-1: oklch(0.6231 0.188 259.8145) /* Blue */ --chart-2: oklch(0.5461 0.2152 262.8809)
--chart-2: oklch(0.5461 0.2152 262.8809) /* Purple-blue */ /* Purple-blue */ --chart-3: oklch(0.4882 0.2172 264.3763) /* Deep purple */
--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)
--chart-4: oklch(0.4244 0.1809 265.6377) /* Violet */ /* Deep violet */;
--chart-5: oklch(0.3791 0.1378 265.5222) /* Deep violet */
``` ```
**Usage**: **Usage**:
```tsx ```tsx
// In chart components // In chart components
const COLORS = [ const COLORS = [
@@ -436,12 +432,13 @@ What's the purpose?
### Font Families ### Font Families
```css ```css
--font-sans: Geist Sans, system-ui, -apple-system, sans-serif --font-sans:
--font-mono: Geist Mono, ui-monospace, monospace Geist Sans, system-ui, -apple-system, sans-serif --font-mono: Geist Mono, ui-monospace,
--font-serif: ui-serif, Georgia, serif monospace --font-serif: ui-serif, Georgia, serif;
``` ```
**Usage**: **Usage**:
```tsx ```tsx
// Sans serif (default) // Sans serif (default)
<div className="font-sans">Body text</div> <div className="font-sans">Body text</div>
@@ -457,21 +454,21 @@ What's the purpose?
### Type Scale ### Type Scale
| Size | Class | rem | px | Use Case | | Size | Class | rem | px | Use Case |
|------|-------|-----|----|----| | ---- | ----------- | -------- | ----- | ---------------------------- |
| 9xl | `text-9xl` | 8rem | 128px | Hero text (rare) | | 9xl | `text-9xl` | 8rem | 128px | Hero text (rare) |
| 8xl | `text-8xl` | 6rem | 96px | Hero text (rare) | | 8xl | `text-8xl` | 6rem | 96px | Hero text (rare) |
| 7xl | `text-7xl` | 4.5rem | 72px | Hero text (rare) | | 7xl | `text-7xl` | 4.5rem | 72px | Hero text (rare) |
| 6xl | `text-6xl` | 3.75rem | 60px | Hero text (rare) | | 6xl | `text-6xl` | 3.75rem | 60px | Hero text (rare) |
| 5xl | `text-5xl` | 3rem | 48px | Landing page H1 | | 5xl | `text-5xl` | 3rem | 48px | Landing page H1 |
| 4xl | `text-4xl` | 2.25rem | 36px | Page H1 | | 4xl | `text-4xl` | 2.25rem | 36px | Page H1 |
| 3xl | `text-3xl` | 1.875rem | 30px | **Page titles** | | 3xl | `text-3xl` | 1.875rem | 30px | **Page titles** |
| 2xl | `text-2xl` | 1.5rem | 24px | **Section headings** | | 2xl | `text-2xl` | 1.5rem | 24px | **Section headings** |
| xl | `text-xl` | 1.25rem | 20px | **Card titles** | | xl | `text-xl` | 1.25rem | 20px | **Card titles** |
| lg | `text-lg` | 1.125rem | 18px | **Subheadings** | | lg | `text-lg` | 1.125rem | 18px | **Subheadings** |
| base | `text-base` | 1rem | 16px | **Body text (default)** | | base | `text-base` | 1rem | 16px | **Body text (default)** |
| sm | `text-sm` | 0.875rem | 14px | **Secondary text, captions** | | sm | `text-sm` | 0.875rem | 14px | **Secondary text, captions** |
| xs | `text-xs` | 0.75rem | 12px | **Labels, helper text** | | xs | `text-xs` | 0.75rem | 12px | **Labels, helper text** |
**Bold = most commonly used** **Bold = most commonly used**
@@ -479,13 +476,13 @@ What's the purpose?
### Font Weights ### Font Weights
| Weight | Class | Numeric | Use Case | | Weight | Class | Numeric | Use Case |
|--------|-------|---------|----------| | -------- | --------------- | ------- | ------------------------ |
| Bold | `font-bold` | 700 | **Headings, emphasis** | | Bold | `font-bold` | 700 | **Headings, emphasis** |
| Semibold | `font-semibold` | 600 | **Subheadings, buttons** | | Semibold | `font-semibold` | 600 | **Subheadings, buttons** |
| Medium | `font-medium` | 500 | **Labels, menu items** | | Medium | `font-medium` | 500 | **Labels, menu items** |
| Normal | `font-normal` | 400 | **Body text (default)** | | Normal | `font-normal` | 400 | **Body text (default)** |
| Light | `font-light` | 300 | De-emphasized text | | Light | `font-light` | 300 | De-emphasized text |
**Bold = most commonly used** **Bold = most commonly used**
@@ -494,35 +491,37 @@ What's the purpose?
### Typography Patterns ### Typography Patterns
#### Page Title #### Page Title
```tsx ```tsx
<h1 className="text-3xl font-bold">Page Title</h1> <h1 className="text-3xl font-bold">Page Title</h1>
``` ```
#### Section Heading #### Section Heading
```tsx ```tsx
<h2 className="text-2xl font-semibold mb-4">Section Heading</h2> <h2 className="text-2xl font-semibold mb-4">Section Heading</h2>
``` ```
#### Card Title #### Card Title
```tsx ```tsx
<CardTitle className="text-xl font-semibold">Card Title</CardTitle> <CardTitle className="text-xl font-semibold">Card Title</CardTitle>
``` ```
#### Body Text #### Body Text
```tsx ```tsx
<p className="text-base text-foreground"> <p className="text-base text-foreground">Regular paragraph text uses the default text-base size.</p>
Regular paragraph text uses the default text-base size.
</p>
``` ```
#### Secondary Text #### Secondary Text
```tsx ```tsx
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Helper text, timestamps, captions</p>
Helper text, timestamps, captions
</p>
``` ```
#### Label #### Label
```tsx ```tsx
<Label htmlFor="email" className="text-sm font-medium"> <Label htmlFor="email" className="text-sm font-medium">
Email Address Email Address
@@ -533,16 +532,17 @@ What's the purpose?
### Line Height ### Line Height
| Class | Value | Use Case | | Class | Value | Use Case |
|-------|-------|----------| | ----------------- | ----- | ----------------------- |
| `leading-none` | 1 | Headings (rare) | | `leading-none` | 1 | Headings (rare) |
| `leading-tight` | 1.25 | **Headings** | | `leading-tight` | 1.25 | **Headings** |
| `leading-snug` | 1.375 | Dense text | | `leading-snug` | 1.375 | Dense text |
| `leading-normal` | 1.5 | **Body text (default)** | | `leading-normal` | 1.5 | **Body text (default)** |
| `leading-relaxed` | 1.625 | Comfortable reading | | `leading-relaxed` | 1.625 | Comfortable reading |
| `leading-loose` | 2 | Very relaxed (rare) | | `leading-loose` | 2 | Very relaxed (rare) |
**Usage**: **Usage**:
```tsx ```tsx
// Heading // Heading
<h1 className="text-3xl font-bold leading-tight"> <h1 className="text-3xl font-bold leading-tight">
@@ -622,23 +622,23 @@ Tailwind uses a **0.25rem (4px) base unit**:
### Spacing Tokens ### Spacing Tokens
| Token | rem | Pixels | Use Case | | Token | rem | Pixels | Use Case |
|-------|-----|--------|----------| | ----- | -------- | ------ | ---------------------------------- |
| `0` | 0 | 0px | No spacing | | `0` | 0 | 0px | No spacing |
| `px` | - | 1px | Borders, dividers | | `px` | - | 1px | Borders, dividers |
| `0.5` | 0.125rem | 2px | Very tight | | `0.5` | 0.125rem | 2px | Very tight |
| `1` | 0.25rem | 4px | Icon gaps | | `1` | 0.25rem | 4px | Icon gaps |
| `2` | 0.5rem | 8px | **Tight spacing** (label → input) | | `2` | 0.5rem | 8px | **Tight spacing** (label → input) |
| `3` | 0.75rem | 12px | Component padding | | `3` | 0.75rem | 12px | Component padding |
| `4` | 1rem | 16px | **Standard spacing** (form fields) | | `4` | 1rem | 16px | **Standard spacing** (form fields) |
| `5` | 1.25rem | 20px | Medium spacing | | `5` | 1.25rem | 20px | Medium spacing |
| `6` | 1.5rem | 24px | **Section spacing** (cards) | | `6` | 1.5rem | 24px | **Section spacing** (cards) |
| `8` | 2rem | 32px | **Large gaps** | | `8` | 2rem | 32px | **Large gaps** |
| `10` | 2.5rem | 40px | Very large gaps | | `10` | 2.5rem | 40px | Very large gaps |
| `12` | 3rem | 48px | **Section dividers** | | `12` | 3rem | 48px | **Section dividers** |
| `16` | 4rem | 64px | **Page sections** | | `16` | 4rem | 64px | **Page sections** |
| `20` | 5rem | 80px | Extra large | | `20` | 5rem | 80px | Extra large |
| `24` | 6rem | 96px | Huge spacing | | `24` | 6rem | 96px | Huge spacing |
**Bold = most commonly used** **Bold = most commonly used**
@@ -660,18 +660,18 @@ Tailwind uses a **0.25rem (4px) base unit**:
### Max Width Scale ### Max Width Scale
| Class | Pixels | Use Case | | Class | Pixels | Use Case |
|-------|--------|----------| | ----------- | ------ | ------------------- |
| `max-w-xs` | 320px | Tiny cards | | `max-w-xs` | 320px | Tiny cards |
| `max-w-sm` | 384px | Small cards | | `max-w-sm` | 384px | Small cards |
| `max-w-md` | 448px | **Forms** | | `max-w-md` | 448px | **Forms** |
| `max-w-lg` | 512px | **Modals** | | `max-w-lg` | 512px | **Modals** |
| `max-w-xl` | 576px | Medium content | | `max-w-xl` | 576px | Medium content |
| `max-w-2xl` | 672px | **Article content** | | `max-w-2xl` | 672px | **Article content** |
| `max-w-3xl` | 768px | Documentation | | `max-w-3xl` | 768px | Documentation |
| `max-w-4xl` | 896px | **Wide layouts** | | `max-w-4xl` | 896px | **Wide layouts** |
| `max-w-5xl` | 1024px | Extra wide | | `max-w-5xl` | 1024px | Extra wide |
| `max-w-6xl` | 1152px | Very wide | | `max-w-6xl` | 1152px | Very wide |
| `max-w-7xl` | 1280px | **Full page width** | | `max-w-7xl` | 1280px | **Full page width** |
**Bold = most commonly used** **Bold = most commonly used**
@@ -729,27 +729,28 @@ Tailwind uses a **0.25rem (4px) base unit**:
Professional shadow system for depth and elevation: Professional shadow system for depth and elevation:
```css ```css
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05) --shadow-xs:
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10) 0 1px 3px 0px hsl(0 0% 0% / 0.05) --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10) 0 1px 2px -1px hsl(0 0% 0% / 0.1) --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10) 0 1px 2px -1px hsl(0 0% 0% / 0.1) --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10) 0 2px 4px -1px hsl(0 0% 0% / 0.1) --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10) 0 4px 6px -1px hsl(0 0% 0% / 0.1) --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25) 0 8px 10px -1px hsl(0 0% 0% / 0.1) --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
``` ```
### Shadow Usage ### Shadow Usage
| Elevation | Class | Use Case | | Elevation | Class | Use Case |
|-----------|-------|----------| | --------- | ------------ | -------------------------------- |
| Base | No shadow | Buttons, inline elements | | Base | No shadow | Buttons, inline elements |
| Low | `shadow-sm` | **Cards, panels** | | Low | `shadow-sm` | **Cards, panels** |
| Medium | `shadow-md` | **Dropdowns, tooltips** | | Medium | `shadow-md` | **Dropdowns, tooltips** |
| High | `shadow-lg` | **Modals, popovers** | | High | `shadow-lg` | **Modals, popovers** |
| Highest | `shadow-xl` | Notifications, floating elements | | Highest | `shadow-xl` | Notifications, floating elements |
| Maximum | `shadow-2xl` | Dialogs (rare) | | Maximum | `shadow-2xl` | Dialogs (rare) |
**Usage**: **Usage**:
```tsx ```tsx
// Card with subtle shadow // Card with subtle shadow
<Card className="shadow-sm">Card content</Card> <Card className="shadow-sm">Card content</Card>
@@ -779,26 +780,24 @@ Professional shadow system for depth and elevation:
Consistent rounded corners across the application: Consistent rounded corners across the application:
```css ```css
--radius: 0.375rem; /* 6px - base */ --radius: 0.375rem; /* 6px - base */
--radius-sm: calc(var(--radius) - 4px) /* 2px */ --radius-sm: calc(var(--radius) - 4px) /* 2px */ --radius-md: calc(var(--radius) - 2px) /* 4px */
--radius-md: calc(var(--radius) - 2px) /* 4px */ --radius-lg: var(--radius) /* 6px */ --radius-xl: calc(var(--radius) + 4px) /* 10px */;
--radius-lg: var(--radius) /* 6px */
--radius-xl: calc(var(--radius) + 4px) /* 10px */
``` ```
### Border Radius Scale ### Border Radius Scale
| Token | Class | Pixels | Use Case | | Token | Class | Pixels | Use Case |
|-------|-------|--------|----------| | ------ | -------------- | ------ | -------------------------------- |
| None | `rounded-none` | 0px | Square elements | | None | `rounded-none` | 0px | Square elements |
| Small | `rounded-sm` | 2px | **Tags, small badges** | | Small | `rounded-sm` | 2px | **Tags, small badges** |
| Medium | `rounded-md` | 4px | **Inputs, small buttons** | | Medium | `rounded-md` | 4px | **Inputs, small buttons** |
| Large | `rounded-lg` | 6px | **Cards, buttons (default)** | | Large | `rounded-lg` | 6px | **Cards, buttons (default)** |
| XL | `rounded-xl` | 10px | **Large cards, modals** | | XL | `rounded-xl` | 10px | **Large cards, modals** |
| 2XL | `rounded-2xl` | 16px | Hero sections | | 2XL | `rounded-2xl` | 16px | Hero sections |
| 3XL | `rounded-3xl` | 24px | Very rounded | | 3XL | `rounded-3xl` | 24px | Very rounded |
| Full | `rounded-full` | 9999px | **Pills, avatars, icon buttons** | | Full | `rounded-full` | 9999px | **Pills, avatars, icon buttons** |
**Bold = most commonly used** **Bold = most commonly used**
@@ -854,6 +853,7 @@ Consistent rounded corners across the application:
### Most Used Tokens ### Most Used Tokens
**Colors**: **Colors**:
- `bg-primary text-primary-foreground` - CTAs - `bg-primary text-primary-foreground` - CTAs
- `bg-destructive text-destructive-foreground` - Delete/errors - `bg-destructive text-destructive-foreground` - Delete/errors
- `bg-muted text-muted-foreground` - Disabled/subtle - `bg-muted text-muted-foreground` - Disabled/subtle
@@ -862,6 +862,7 @@ Consistent rounded corners across the application:
- `border-border` - Borders - `border-border` - Borders
**Typography**: **Typography**:
- `text-3xl font-bold` - Page titles - `text-3xl font-bold` - Page titles
- `text-2xl font-semibold` - Section headings - `text-2xl font-semibold` - Section headings
- `text-xl font-semibold` - Card titles - `text-xl font-semibold` - Card titles
@@ -869,6 +870,7 @@ Consistent rounded corners across the application:
- `text-sm text-muted-foreground` - Secondary text - `text-sm text-muted-foreground` - Secondary text
**Spacing**: **Spacing**:
- `p-4` - Standard padding (16px) - `p-4` - Standard padding (16px)
- `p-6` - Card padding (24px) - `p-6` - Card padding (24px)
- `gap-4` - Standard gap (16px) - `gap-4` - Standard gap (16px)
@@ -877,6 +879,7 @@ Consistent rounded corners across the application:
- `space-y-6` - Section spacing (24px) - `space-y-6` - Section spacing (24px)
**Shadows & Radius**: **Shadows & Radius**:
- `shadow-sm` - Cards - `shadow-sm` - Cards
- `shadow-md` - Dropdowns - `shadow-md` - Dropdowns
- `shadow-lg` - Modals - `shadow-lg` - Modals
@@ -896,12 +899,14 @@ Consistent rounded corners across the application:
--- ---
**Related Documentation:** **Related Documentation:**
- [Quick Start](./00-quick-start.md) - Essential patterns - [Quick Start](./00-quick-start.md) - Essential patterns
- [Components](./02-components.md) - shadcn/ui library - [Components](./02-components.md) - shadcn/ui library
- [Spacing Philosophy](./04-spacing-philosophy.md) - Margin vs padding strategy - [Spacing Philosophy](./04-spacing-philosophy.md) - Margin vs padding strategy
- [Accessibility](./07-accessibility.md) - WCAG compliance - [Accessibility](./07-accessibility.md) - WCAG compliance
**External Resources:** **External Resources:**
- [OKLCH Color Picker](https://oklch.com) - [OKLCH Color Picker](https://oklch.com)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) - [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [Tailwind CSS Documentation](https://tailwindcss.com/docs) - [Tailwind CSS Documentation](https://tailwindcss.com/docs)

View File

@@ -24,6 +24,7 @@
We use **[shadcn/ui](https://ui.shadcn.com)**, a collection of accessible, customizable components built on **Radix UI primitives**. We use **[shadcn/ui](https://ui.shadcn.com)**, a collection of accessible, customizable components built on **Radix UI primitives**.
**Key features:** **Key features:**
-**Accessible** - WCAG AA compliant, keyboard navigation, screen reader support -**Accessible** - WCAG AA compliant, keyboard navigation, screen reader support
-**Customizable** - Components are copied into your project (not npm dependencies) -**Customizable** - Components are copied into your project (not npm dependencies)
-**Composable** - Build complex UIs from simple primitives -**Composable** - Build complex UIs from simple primitives
@@ -41,6 +42,7 @@ npx shadcn@latest add
``` ```
**Installed components** (in `/src/components/ui/`): **Installed components** (in `/src/components/ui/`):
- alert, avatar, badge, button, card, checkbox, dialog - alert, avatar, badge, button, card, checkbox, dialog
- dropdown-menu, input, label, popover, select, separator - dropdown-menu, input, label, popover, select, separator
- sheet, skeleton, table, tabs, textarea, toast - sheet, skeleton, table, tabs, textarea, toast
@@ -82,16 +84,17 @@ import { Button } from '@/components/ui/button';
**When to use each variant:** **When to use each variant:**
| Variant | Use Case | Example | | Variant | Use Case | Example |
|---------|----------|---------| | ------------- | ----------------------- | -------------------------- |
| `default` | Primary actions, CTAs | Save, Submit, Create | | `default` | Primary actions, CTAs | Save, Submit, Create |
| `secondary` | Secondary actions | Cancel, Back | | `secondary` | Secondary actions | Cancel, Back |
| `outline` | Alternative actions | View Details, Edit | | `outline` | Alternative actions | View Details, Edit |
| `ghost` | Subtle actions in lists | Icon buttons in table rows | | `ghost` | Subtle actions in lists | Icon buttons in table rows |
| `link` | In-text actions | Read more, Learn more | | `link` | In-text actions | Read more, Learn more |
| `destructive` | Delete, remove actions | Delete Account, Remove | | `destructive` | Delete, remove actions | Delete Account, Remove |
**Accessibility**: **Accessibility**:
- Always add `aria-label` for icon-only buttons - Always add `aria-label` for icon-only buttons
- Use `disabled` for unavailable actions (not hidden) - Use `disabled` for unavailable actions (not hidden)
- Loading state prevents double-submission - Loading state prevents double-submission
@@ -162,6 +165,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
``` ```
**Pattern: User menu**: **Pattern: User menu**:
```tsx ```tsx
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
@@ -323,6 +327,7 @@ import { Label } from '@/components/ui/label';
``` ```
**Input types:** **Input types:**
- `text` - Default text input - `text` - Default text input
- `email` - Email address - `email` - Email address
- `password` - Password field - `password` - Password field
@@ -530,6 +535,7 @@ import { AlertCircle, CheckCircle, Info } from 'lucide-react';
``` ```
**When to use:** **When to use:**
- ✅ Form-level errors - ✅ Form-level errors
- ✅ Important warnings - ✅ Important warnings
- ✅ Success confirmations (inline) - ✅ Success confirmations (inline)
@@ -557,14 +563,11 @@ toast.info('Processing your request...');
toast.warning('This action cannot be undone'); toast.warning('This action cannot be undone');
// Loading (with promise) // Loading (with promise)
toast.promise( toast.promise(saveChanges(), {
saveChanges(), loading: 'Saving changes...',
{ success: 'Changes saved!',
loading: 'Saving changes...', error: 'Failed to save changes',
success: 'Changes saved!', });
error: 'Failed to save changes',
}
);
// Custom with action // Custom with action
toast('Event has been created', { toast('Event has been created', {
@@ -580,6 +583,7 @@ toast.dismiss();
``` ```
**When to use:** **When to use:**
- ✅ Action confirmations (saved, deleted) - ✅ Action confirmations (saved, deleted)
- ✅ Background task updates - ✅ Background task updates
- ✅ Temporary errors - ✅ Temporary errors
@@ -629,12 +633,11 @@ import { Skeleton } from '@/components/ui/skeleton';
``` ```
**Pattern: Loading states**: **Pattern: Loading states**:
```tsx ```tsx
{isLoading ? ( {
<Skeleton className="h-48 w-full" /> isLoading ? <Skeleton className="h-48 w-full" /> : <div>{content}</div>;
) : ( }
<div>{content}</div>
)}
``` ```
--- ---
@@ -654,7 +657,7 @@ import {
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogTrigger, DialogTrigger,
DialogClose DialogClose,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
// Basic dialog // Basic dialog
@@ -678,7 +681,7 @@ import {
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>;
// Controlled dialog // Controlled dialog
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -695,10 +698,11 @@ const [isOpen, setIsOpen] = useState(false);
}} }}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>;
``` ```
**Accessibility:** **Accessibility:**
- Escape key closes dialog - Escape key closes dialog
- Focus trapped inside dialog - Focus trapped inside dialog
- Returns focus to trigger on close - Returns focus to trigger on close
@@ -916,7 +920,7 @@ import {
TableHead, TableHead,
TableRow, TableRow,
TableCell, TableCell,
TableCaption TableCaption,
} from '@/components/ui/table'; } from '@/components/ui/table';
<Table> <Table>
@@ -945,7 +949,7 @@ import {
<TableCell className="text-right">$2,500.00</TableCell> <TableCell className="text-right">$2,500.00</TableCell>
</TableRow> </TableRow>
</TableFooter> </TableFooter>
</Table> </Table>;
``` ```
**For advanced tables** (sorting, filtering, pagination), use **TanStack Table** with react-hook-form. **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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.map(user => ( {users.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell>{user.name}</TableCell> <TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell> <TableCell>{user.email}</TableCell>
<TableCell> <TableCell>
<Button variant="ghost" size="sm">Edit</Button> <Button variant="ghost" size="sm">
Edit
</Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -1041,9 +1047,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Create New User</DialogTitle> <DialogTitle>Create New User</DialogTitle>
<DialogDescription> <DialogDescription>Add a new user to the system</DialogDescription>
Add a new user to the system
</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -1113,7 +1117,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
```tsx ```tsx
<Table> <Table>
<TableBody> <TableBody>
{users.map(user => ( {users.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell>{user.name}</TableCell> <TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell> <TableCell>{user.email}</TableCell>
@@ -1134,10 +1138,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
View Details View Details
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem onClick={() => handleDelete(user)} className="text-destructive">
onClick={() => handleDelete(user)}
className="text-destructive"
>
<Trash className="mr-2 h-4 w-4" /> <Trash className="mr-2 h-4 w-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
@@ -1187,6 +1188,7 @@ Need switchable panels? → Tabs
### Component Variants Quick Reference ### Component Variants Quick Reference
**Button**: **Button**:
- `default` - Primary action - `default` - Primary action
- `secondary` - Secondary action - `secondary` - Secondary action
- `outline` - Alternative action - `outline` - Alternative action
@@ -1195,12 +1197,14 @@ Need switchable panels? → Tabs
- `destructive` - Delete/remove - `destructive` - Delete/remove
**Badge**: **Badge**:
- `default` - Blue (new, active) - `default` - Blue (new, active)
- `secondary` - Gray (draft, inactive) - `secondary` - Gray (draft, inactive)
- `outline` - Bordered (pending) - `outline` - Bordered (pending)
- `destructive` - Red (critical, error) - `destructive` - Red (critical, error)
**Alert**: **Alert**:
- `default` - Info - `default` - Info
- `destructive` - Error - `destructive` - Error
@@ -1216,12 +1220,14 @@ Need switchable panels? → Tabs
--- ---
**Related Documentation:** **Related Documentation:**
- [Quick Start](./00-quick-start.md) - Essential patterns - [Quick Start](./00-quick-start.md) - Essential patterns
- [Foundations](./01-foundations.md) - Colors, typography, spacing - [Foundations](./01-foundations.md) - Colors, typography, spacing
- [Layouts](./03-layouts.md) - Layout patterns - [Layouts](./03-layouts.md) - Layout patterns
- [Forms](./06-forms.md) - Form validation and patterns - [Forms](./06-forms.md) - Form validation and patterns
**External Resources:** **External Resources:**
- [shadcn/ui Documentation](https://ui.shadcn.com) - [shadcn/ui Documentation](https://ui.shadcn.com)
- [Radix UI Primitives](https://www.radix-ui.com) - [Radix UI Primitives](https://www.radix-ui.com)

View File

@@ -36,16 +36,16 @@ Use this flowchart to choose between Grid and Flex:
### Quick Rules ### Quick Rules
| Scenario | Solution | | Scenario | Solution |
|----------|----------| | --------------------------- | ------------------------------------------------------- |
| **Equal-width columns** | Grid (`grid grid-cols-3`) | | **Equal-width columns** | Grid (`grid grid-cols-3`) |
| **Flexible item sizes** | Flex (`flex gap-4`) | | **Flexible item sizes** | Flex (`flex gap-4`) |
| **2D layout (rows + cols)** | Grid (`grid grid-cols-2 grid-rows-3`) | | **2D layout (rows + cols)** | Grid (`grid grid-cols-2 grid-rows-3`) |
| **1D layout (row OR col)** | Flex (`flex` or `flex flex-col`) | | **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`) | | **Card grid** | Grid (`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3`) |
| **Navbar items** | Flex (`flex items-center gap-4`) | | **Navbar items** | Flex (`flex items-center gap-4`) |
| **Sidebar + Content** | Flex (`flex gap-6`) | | **Sidebar + Content** | Flex (`flex gap-6`) |
| **Form fields** | Flex column (`flex flex-col gap-4` or `space-y-4`) | | **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> <CardHeader>
<CardTitle>Section Title</CardTitle> <CardTitle>Section Title</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>Page content goes here</CardContent>
Page content goes here
</CardContent>
</Card> </Card>
</div> </div>
</div> </div>
``` ```
**Key Features:** **Key Features:**
- `container` - Responsive container with max-width - `container` - Responsive container with max-width
- `mx-auto` - Center horizontally - `mx-auto` - Center horizontally
- `px-4` - Horizontal padding (mobile-friendly) - `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 - `space-y-6` - Vertical spacing between children
**When to use:** **When to use:**
- Blog posts - Blog posts
- Documentation pages - Documentation pages
- Settings 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> <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"> <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}> <Card key={item.id}>
<CardHeader> <CardHeader>
<CardTitle>{item.title}</CardTitle> <CardTitle>{item.title}</CardTitle>
@@ -119,11 +119,13 @@ These 5 patterns cover 80% of all layout needs. Master these first.
``` ```
**Responsive behavior:** **Responsive behavior:**
- **Mobile** (`< 768px`): 1 column - **Mobile** (`< 768px`): 1 column
- **Tablet** (`≥ 768px`): 2 columns - **Tablet** (`≥ 768px`): 2 columns
- **Desktop** (`≥ 1024px`): 3 columns - **Desktop** (`≥ 1024px`): 3 columns
**Key Features:** **Key Features:**
- `grid` - Use CSS Grid - `grid` - Use CSS Grid
- `grid-cols-1` - Default: 1 column (mobile-first) - `grid-cols-1` - Default: 1 column (mobile-first)
- `md:grid-cols-2` - 2 columns on tablet - `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 - `gap-6` - Consistent spacing between items
**When to use:** **When to use:**
- Dashboards - Dashboards
- Product grids - Product grids
- Image galleries - Image galleries
@@ -171,17 +174,20 @@ These 5 patterns cover 80% of all layout needs. Master these first.
``` ```
**Key Features:** **Key Features:**
- `max-w-md` - Constrain form width (448px max) - `max-w-md` - Constrain form width (448px max)
- `mx-auto` - Center the form - `mx-auto` - Center the form
- `space-y-4` - Vertical spacing between fields - `space-y-4` - Vertical spacing between fields
- `w-full` - Full-width button - `w-full` - Full-width button
**Form width guidelines:** **Form width guidelines:**
- **Short forms** (login, signup): `max-w-md` (448px) - **Short forms** (login, signup): `max-w-md` (448px)
- **Medium forms** (profile, settings): `max-w-lg` (512px) - **Medium forms** (profile, settings): `max-w-lg` (512px)
- **Long forms** (checkout): `max-w-2xl` (672px) - **Long forms** (checkout): `max-w-2xl` (672px)
**When to use:** **When to use:**
- Login/signup forms - Login/signup forms
- Contact forms - Contact forms
- Settings forms - Settings forms
@@ -220,6 +226,7 @@ These 5 patterns cover 80% of all layout needs. Master these first.
``` ```
**Key Features:** **Key Features:**
- `flex` - Horizontal layout - `flex` - Horizontal layout
- `w-64` - Fixed sidebar width (256px) - `w-64` - Fixed sidebar width (256px)
- `flex-1` - Main content takes remaining space - `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:** **When to use:**
- Admin dashboards - Admin dashboards
- Settings pages - Settings pages
- Documentation sites - Documentation sites
@@ -277,17 +285,20 @@ These 5 patterns cover 80% of all layout needs. Master these first.
``` ```
**Key Features:** **Key Features:**
- `max-w-2xl` - Optimal reading width (672px) - `max-w-2xl` - Optimal reading width (672px)
- `mx-auto` - Center content - `mx-auto` - Center content
- `prose` - Typography styles (if using @tailwindcss/typography) - `prose` - Typography styles (if using @tailwindcss/typography)
**Width recommendations:** **Width recommendations:**
- **Articles/Blogs**: `max-w-2xl` (672px) - **Articles/Blogs**: `max-w-2xl` (672px)
- **Documentation**: `max-w-3xl` (768px) - **Documentation**: `max-w-3xl` (768px)
- **Landing pages**: `max-w-4xl` (896px) or wider - **Landing pages**: `max-w-4xl` (896px) or wider
- **Forms**: `max-w-md` (448px) - **Forms**: `max-w-md` (448px)
**When to use:** **When to use:**
- Blog posts - Blog posts
- Articles - Articles
- Documentation - Documentation
@@ -327,13 +338,13 @@ Always start with mobile layout, then enhance for larger screens:
### Breakpoints ### Breakpoints
| Breakpoint | Min Width | Typical Use | | Breakpoint | Min Width | Typical Use |
|------------|-----------|-------------| | ---------- | --------- | --------------------------- |
| `sm:` | 640px | Large phones, small tablets | | `sm:` | 640px | Large phones, small tablets |
| `md:` | 768px | Tablets | | `md:` | 768px | Tablets |
| `lg:` | 1024px | Laptops, desktops | | `lg:` | 1024px | Laptops, desktops |
| `xl:` | 1280px | Large desktops | | `xl:` | 1280px | Large desktops |
| `2xl:` | 1536px | Extra large screens | | `2xl:` | 1536px | Extra large screens |
### Responsive Grid Columns ### Responsive Grid Columns
@@ -457,12 +468,8 @@ grid-cols-1 lg:grid-cols-3
```tsx ```tsx
// 2/3 - 1/3 split // 2/3 - 1/3 split
<div className="grid grid-cols-3 gap-6"> <div className="grid grid-cols-3 gap-6">
<div className="col-span-2"> <div className="col-span-2">Main content (2/3 width)</div>
Main content (2/3 width) <div className="col-span-1">Sidebar (1/3 width)</div>
</div>
<div className="col-span-1">
Sidebar (1/3 width)
</div>
</div> </div>
``` ```
@@ -482,12 +489,8 @@ grid-cols-1 lg:grid-cols-3
```tsx ```tsx
<div className="flex gap-6"> <div className="flex gap-6">
<aside className="sticky top-6 h-fit w-64"> <aside className="sticky top-6 h-fit w-64">{/* Stays in view while scrolling */}</aside>
{/* Stays in view while scrolling */} <main className="flex-1">{/* Scrollable content */}</main>
</aside>
<main className="flex-1">
{/* Scrollable content */}
</main>
</div> </div>
``` ```
@@ -579,6 +582,7 @@ w-full px-4
--- ---
**Related Documentation:** **Related Documentation:**
- [Spacing Philosophy](./04-spacing-philosophy.md) - When to use margin vs padding vs gap - [Spacing Philosophy](./04-spacing-philosophy.md) - When to use margin vs padding vs gap
- [Foundations](./01-foundations.md) - Spacing tokens and scale - [Foundations](./01-foundations.md) - Spacing tokens and scale
- [Quick Start](./00-quick-start.md) - Essential patterns - [Quick Start](./00-quick-start.md) - Essential patterns

View File

@@ -21,6 +21,7 @@
These 5 rules eliminate 90% of spacing inconsistencies: These 5 rules eliminate 90% of spacing inconsistencies:
### Rule 1: Parent Controls Children ### Rule 1: Parent Controls Children
**Children don't add their own margins. The parent controls spacing between siblings.** **Children don't add their own margins. The parent controls spacing between siblings.**
```tsx ```tsx
@@ -40,6 +41,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
``` ```
**Why this matters:** **Why this matters:**
- Eliminates "last child" edge cases - Eliminates "last child" edge cases
- Makes components reusable (they work in any context) - Makes components reusable (they work in any context)
- Changes propagate from one place (parent) - Changes propagate from one place (parent)
@@ -48,6 +50,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
--- ---
### Rule 2: Use Gap for Siblings ### Rule 2: Use Gap for Siblings
**For flex and grid layouts, use `gap-*` to space siblings.** **For flex and grid layouts, use `gap-*` to space siblings.**
```tsx ```tsx
@@ -73,6 +76,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
--- ---
### Rule 3: Use Padding for Internal Spacing ### Rule 3: Use Padding for Internal Spacing
**Padding is for spacing _inside_ a component, between the border and content.** **Padding is for spacing _inside_ a component, between the border and content.**
```tsx ```tsx
@@ -91,6 +95,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
--- ---
### Rule 4: Use space-y for Vertical Stacks ### Rule 4: Use space-y for Vertical Stacks
**For vertical stacks (not flex/grid), use `space-y-*` utility.** **For vertical stacks (not flex/grid), use `space-y-*` utility.**
```tsx ```tsx
@@ -110,6 +115,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
``` ```
**How space-y works:** **How space-y works:**
```css ```css
/* space-y-4 applies margin-top to all children except first */ /* space-y-4 applies margin-top to all children except first */
.space-y-4 > * + * { .space-y-4 > * + * {
@@ -120,6 +126,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
--- ---
### Rule 5: Margins Only for Exceptions ### Rule 5: Margins Only for Exceptions
**Use margin only when a specific child needs different spacing from its siblings.** **Use margin only when a specific child needs different spacing from its siblings.**
```tsx ```tsx
@@ -151,18 +158,19 @@ When children control their own margins:
```tsx ```tsx
// ❌ ANTI-PATTERN // ❌ ANTI-PATTERN
function TodoItem({ className }: { className?: string }) { function TodoItem({ className }: { className?: string }) {
return <div className={cn("mb-4", className)}>Todo</div>; return <div className={cn('mb-4', className)}>Todo</div>;
} }
// Usage // Usage
<div> <div>
<TodoItem /> {/* Has mb-4 */} <TodoItem /> {/* Has mb-4 */}
<TodoItem /> {/* Has mb-4 */} <TodoItem /> {/* Has mb-4 */}
<TodoItem /> {/* Has mb-4 - unwanted margin at bottom! */} <TodoItem /> {/* Has mb-4 - unwanted margin at bottom! */}
</div> </div>;
``` ```
**Problems:** **Problems:**
1. ❌ Last item has unwanted margin 1. ❌ Last item has unwanted margin
2. ❌ Can't change spacing without modifying component 2. ❌ Can't change spacing without modifying component
3. ❌ Margin collapsing creates unpredictable spacing 3. ❌ Margin collapsing creates unpredictable spacing
@@ -199,6 +207,7 @@ function TodoItem({ className }: { className?: string }) {
``` ```
**Benefits:** **Benefits:**
1. ✅ No edge cases (last child, first child, only child) 1. ✅ No edge cases (last child, first child, only child)
2. ✅ Spacing controlled in one place 2. ✅ Spacing controlled in one place
3. ✅ Component works in any layout context 3. ✅ Component works in any layout context
@@ -265,6 +274,7 @@ Use this flowchart to choose the right spacing method:
``` ```
**Spacing breakdown:** **Spacing breakdown:**
- `space-y-4` on form: 16px between field groups - `space-y-4` on form: 16px between field groups
- `space-y-2` on field group: 8px between label and input - `space-y-2` on field group: 8px between label and input
- No margins on children - No margins on children
@@ -288,6 +298,7 @@ Use this flowchart to choose the right spacing method:
``` ```
**Why gap over space-x:** **Why gap over space-x:**
- Works with `flex-wrap` - Works with `flex-wrap`
- Works with `flex-col` (changes direction) - Works with `flex-col` (changes direction)
- Consistent spacing in all directions - Consistent spacing in all directions
@@ -306,6 +317,7 @@ Use this flowchart to choose the right spacing method:
``` ```
**Why gap:** **Why gap:**
- Consistent spacing between rows and columns - Consistent spacing between rows and columns
- Works with responsive grid changes - Works with responsive grid changes
- No edge cases (first row, last column, etc.) - No edge cases (first row, last column, etc.)
@@ -332,6 +344,7 @@ Use this flowchart to choose the right spacing method:
``` ```
**Spacing breakdown:** **Spacing breakdown:**
- `p-6` on Card: 24px internal padding - `p-6` on Card: 24px internal padding
- `space-y-4` on CardContent: 16px between paragraphs - `space-y-4` on CardContent: 16px between paragraphs
- `pt-4` on CardFooter: Additional top padding for visual separation - `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:** **Spacing breakdown:**
- `px-4`: Horizontal padding (prevents edge touching) - `px-4`: Horizontal padding (prevents edge touching)
- `py-8`: Vertical padding (top and bottom spacing) - `py-8`: Vertical padding (top and bottom spacing)
- `space-y-6`: 24px between sections - `space-y-6`: 24px between sections
@@ -376,25 +390,28 @@ Use this flowchart to choose the right spacing method:
### Example 1: Button Group ### Example 1: Button Group
#### ❌ Before (Child-Controlled) #### ❌ Before (Child-Controlled)
```tsx ```tsx
function ActionButton({ children, className }: Props) { function ActionButton({ children, className }: Props) {
return <Button className={cn("mr-4", className)}>{children}</Button>; return <Button className={cn('mr-4', className)}>{children}</Button>;
} }
// Usage // Usage
<div className="flex"> <div className="flex">
<ActionButton>Cancel</ActionButton> <ActionButton>Cancel</ActionButton>
<ActionButton>Save</ActionButton> <ActionButton>Save</ActionButton>
<ActionButton>Delete</ActionButton> {/* Unwanted mr-4 */} <ActionButton>Delete</ActionButton> {/* Unwanted mr-4 */}
</div> </div>;
``` ```
**Problems:** **Problems:**
- Last button has unwanted margin - Last button has unwanted margin
- Can't change spacing without modifying component - Can't change spacing without modifying component
- Hard to use in vertical layout - Hard to use in vertical layout
#### ✅ After (Parent-Controlled) #### ✅ After (Parent-Controlled)
```tsx ```tsx
function ActionButton({ children, className }: Props) { function ActionButton({ children, className }: Props) {
return <Button className={className}>{children}</Button>; return <Button className={className}>{children}</Button>;
@@ -415,6 +432,7 @@ function ActionButton({ children, className }: Props) {
``` ```
**Benefits:** **Benefits:**
- No edge cases - No edge cases
- Reusable in any layout - Reusable in any layout
- Easy to change spacing - Easy to change spacing
@@ -424,6 +442,7 @@ function ActionButton({ children, className }: Props) {
### Example 2: List Items ### Example 2: List Items
#### ❌ Before (Child-Controlled) #### ❌ Before (Child-Controlled)
```tsx ```tsx
function ListItem({ title, description }: Props) { function ListItem({ title, description }: Props) {
return ( return (
@@ -437,16 +456,18 @@ function ListItem({ title, description }: Props) {
<div> <div>
<ListItem title="Item 1" description="..." /> <ListItem title="Item 1" description="..." />
<ListItem title="Item 2" description="..." /> <ListItem title="Item 2" description="..." />
<ListItem title="Item 3" description="..." /> {/* Unwanted mb-6 */} <ListItem title="Item 3" description="..." /> {/* Unwanted mb-6 */}
</div> </div>;
``` ```
**Problems:** **Problems:**
- Last item has unwanted bottom margin - Last item has unwanted bottom margin
- Can't change list spacing without modifying component - Can't change list spacing without modifying component
- Internal `mb-2` hard to override - Internal `mb-2` hard to override
#### ✅ After (Parent-Controlled) #### ✅ After (Parent-Controlled)
```tsx ```tsx
function ListItem({ title, description }: Props) { function ListItem({ title, description }: Props) {
return ( return (
@@ -473,6 +494,7 @@ function ListItem({ title, description }: Props) {
``` ```
**Benefits:** **Benefits:**
- No unwanted margins - No unwanted margins
- Internal spacing controlled by `space-y-2` - Internal spacing controlled by `space-y-2`
- Reusable with different spacings - Reusable with different spacings
@@ -482,6 +504,7 @@ function ListItem({ title, description }: Props) {
### Example 3: Form Fields ### Example 3: Form Fields
#### ❌ Before (Mixed Strategy) #### ❌ Before (Mixed Strategy)
```tsx ```tsx
<form> <form>
<div className="mb-4"> <div className="mb-4">
@@ -494,16 +517,20 @@ function ListItem({ title, description }: Props) {
<Input id="email" className="mt-2" /> <Input id="email" className="mt-2" />
</div> </div>
<Button type="submit" className="mt-6">Submit</Button> <Button type="submit" className="mt-6">
Submit
</Button>
</form> </form>
``` ```
**Problems:** **Problems:**
- Spacing scattered across children - Spacing scattered across children
- Hard to change consistently - Hard to change consistently
- Have to remember `mt-6` for button - Have to remember `mt-6` for button
#### ✅ After (Parent-Controlled) #### ✅ After (Parent-Controlled)
```tsx ```tsx
<form className="space-y-4"> <form className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -516,11 +543,14 @@ function ListItem({ title, description }: Props) {
<Input id="email" /> <Input id="email" />
</div> </div>
<Button type="submit" className="mt-2">Submit</Button> <Button type="submit" className="mt-2">
Submit
</Button>
</form> </form>
``` ```
**Benefits:** **Benefits:**
- Spacing controlled in 2 places: form (`space-y-4`) and field groups (`space-y-2`) - Spacing controlled in 2 places: form (`space-y-4`) and field groups (`space-y-2`)
- Easy to change all field spacing at once - Easy to change all field spacing at once
- Consistent and predictable - Consistent and predictable
@@ -533,18 +563,20 @@ function ListItem({ title, description }: Props) {
```tsx ```tsx
// ❌ WRONG // ❌ WRONG
{items.map((item, index) => ( {
<Card key={item.id} className={index < items.length - 1 ? "mb-4" : ""}> items.map((item, index) => (
{item.name} <Card key={item.id} className={index < items.length - 1 ? 'mb-4' : ''}>
</Card> {item.name}
))} </Card>
));
}
// ✅ CORRECT // ✅ CORRECT
<div className="space-y-4"> <div className="space-y-4">
{items.map(item => ( {items.map((item) => (
<Card key={item.id}>{item.name}</Card> <Card key={item.id}>{item.name}</Card>
))} ))}
</div> </div>;
``` ```
--- ---
@@ -564,6 +596,7 @@ function ListItem({ title, description }: Props) {
``` ```
**Why negative margins are bad:** **Why negative margins are bad:**
- Indicates broken spacing strategy - Indicates broken spacing strategy
- Hard to maintain - Hard to maintain
- Creates coupling between components - Creates coupling between components
@@ -618,26 +651,26 @@ function ListItem({ title, description }: Props) {
### Spacing Method Cheat Sheet ### Spacing Method Cheat Sheet
| Use Case | Method | Example | | Use Case | Method | Example |
|----------|--------|---------| | ----------------------- | ----------- | ------------ |
| **Flex siblings** | `gap-*` | `flex gap-4` | | **Flex siblings** | `gap-*` | `flex gap-4` |
| **Grid siblings** | `gap-*` | `grid gap-6` | | **Grid siblings** | `gap-*` | `grid gap-6` |
| **Vertical stack** | `space-y-*` | `space-y-4` | | **Vertical stack** | `space-y-*` | `space-y-4` |
| **Horizontal stack** | `space-x-*` | `space-x-2` | | **Horizontal stack** | `space-x-*` | `space-x-2` |
| **Inside component** | `p-*` | `p-6` | | **Inside component** | `p-*` | `p-6` |
| **One child exception** | `m-*` | `mt-8` | | **One child exception** | `m-*` | `mt-8` |
### Common Spacing Values ### Common Spacing Values
| Class | Pixels | Usage | | Class | Pixels | Usage |
|-------|--------|-------| | ---------------------- | ----------- | ---------------------- |
| `gap-2` or `space-y-2` | 8px | Tight (label + input) | | `gap-2` or `space-y-2` | 8px | Tight (label + input) |
| `gap-4` or `space-y-4` | 16px | Standard (form fields) | | `gap-4` or `space-y-4` | 16px | Standard (form fields) |
| `gap-6` or `space-y-6` | 24px | Sections (cards) | | `gap-6` or `space-y-6` | 24px | Sections (cards) |
| `gap-8` or `space-y-8` | 32px | Large gaps | | `gap-8` or `space-y-8` | 32px | Large gaps |
| `p-4` | 16px | Standard padding | | `p-4` | 16px | Standard padding |
| `p-6` | 24px | Card padding | | `p-6` | 24px | Card padding |
| `px-4 py-8` | 16px / 32px | Page padding | | `px-4 py-8` | 16px / 32px | Page padding |
### Decision Flowchart (Simplified) ### Decision Flowchart (Simplified)
@@ -682,7 +715,7 @@ Need spacing?
Before implementing spacing, verify: Before implementing spacing, verify:
- [ ] **Parent controls children?** Using gap or space-y/x? - [ ] **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? - [ ] **Consistent method?** Not mixing gap + child margins?
- [ ] **Reusable components?** Work in different contexts? - [ ] **Reusable components?** Work in different contexts?
- [ ] **No edge cases?** No last-child or first-child special handling? - [ ] **No edge cases?** No last-child or first-child special handling?
@@ -700,6 +733,7 @@ Before implementing spacing, verify:
--- ---
**Related Documentation:** **Related Documentation:**
- [Layouts](./03-layouts.md) - When to use Grid vs Flex - [Layouts](./03-layouts.md) - When to use Grid vs Flex
- [Foundations](./01-foundations.md) - Spacing scale tokens - [Foundations](./01-foundations.md) - Spacing scale tokens
- [Component Creation](./05-component-creation.md) - Building reusable components - [Component Creation](./05-component-creation.md) - Building reusable components

View File

@@ -22,6 +22,7 @@
**80% of the time, you should COMPOSE existing shadcn/ui components.** **80% of the time, you should COMPOSE existing shadcn/ui components.**
Only create custom components when: Only create custom components when:
1. ✅ You're reusing the same composition 3+ times 1. ✅ You're reusing the same composition 3+ times
2. ✅ The pattern has complex business logic 2. ✅ The pattern has complex business logic
3. ✅ You need variants beyond what shadcn/ui provides 3. ✅ You need variants beyond what shadcn/ui provides
@@ -74,6 +75,7 @@ Do you need a UI element?
``` ```
**Why this is good:** **Why this is good:**
- Simple and direct - Simple and direct
- Easy to customize per use case - Easy to customize per use case
- No abstraction overhead - No abstraction overhead
@@ -103,10 +105,11 @@ function ContentCard({ title, description, content, actionLabel, onAction }: Pro
} }
// Used once... why did we create this? // Used once... why did we create this?
<ContentCard title="..." description="..." content="..." /> <ContentCard title="..." description="..." content="..." />;
``` ```
**Problems:** **Problems:**
- ❌ Created before knowing if pattern is reused - ❌ Created before knowing if pattern is reused
- ❌ Inflexible (what if we need 2 buttons?) - ❌ Inflexible (what if we need 2 buttons?)
- ❌ Unclear what it renders (abstraction hides structure) - ❌ Unclear what it renders (abstraction hides structure)
@@ -148,6 +151,7 @@ function DashboardMetricCard({
``` ```
**Why this works:** **Why this works:**
- ✅ Pattern validated (used 3+ times) - ✅ Pattern validated (used 3+ times)
- ✅ Specific purpose (dashboard metrics) - ✅ Specific purpose (dashboard metrics)
- ✅ Consistent structure across uses - ✅ Consistent structure across uses
@@ -171,22 +175,23 @@ interface MyComponentProps {
export function MyComponent({ className, children }: MyComponentProps) { export function MyComponent({ className, children }: MyComponentProps) {
return ( return (
<div className={cn( <div
"base-classes-here", // Base styles className={cn(
className // Allow overrides 'base-classes-here', // Base styles
)}> className // Allow overrides
)}
>
{children} {children}
</div> </div>
); );
} }
// Usage // Usage
<MyComponent className="custom-overrides"> <MyComponent className="custom-overrides">Content</MyComponent>;
Content
</MyComponent>
``` ```
**Key points:** **Key points:**
- Always accept `className` prop - Always accept `className` prop
- Use `cn()` utility for merging - Use `cn()` utility for merging
- Base classes first, overrides last - Base classes first, overrides last
@@ -203,24 +208,24 @@ import { cn } from '@/lib/utils';
const componentVariants = cva( const componentVariants = cva(
// Base classes (always applied) // 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: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: 'hover:bg-accent hover:text-accent-foreground',
}, },
size: { size: {
sm: "h-8 px-3 text-xs", sm: 'h-8 px-3 text-xs',
default: "h-10 px-4 text-sm", default: 'h-10 px-4 text-sm',
lg: "h-12 px-6 text-base", lg: 'h-12 px-6 text-base',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} }
); );
@@ -231,25 +236,18 @@ interface MyComponentProps
// Additional props here // Additional props here
} }
export function MyComponent({ export function MyComponent({ variant, size, className, ...props }: MyComponentProps) {
variant, return <div className={cn(componentVariants({ variant, size, className }))} {...props} />;
size,
className,
...props
}: MyComponentProps) {
return (
<div
className={cn(componentVariants({ variant, size, className }))}
{...props}
/>
);
} }
// Usage // Usage
<MyComponent variant="outline" size="lg">Content</MyComponent> <MyComponent variant="outline" size="lg">
Content
</MyComponent>;
``` ```
**Key points:** **Key points:**
- Use CVA for complex variant logic - Use CVA for complex variant logic
- Always provide `defaultVariants` - Always provide `defaultVariants`
- Extend `React.HTMLAttributes` for standard HTML props - Extend `React.HTMLAttributes` for standard HTML props
@@ -273,13 +271,7 @@ interface StatCardProps {
className?: string; className?: string;
} }
export function StatCard({ export function StatCard({ title, value, description, icon, className }: StatCardProps) {
title,
value,
description,
icon,
className,
}: StatCardProps) {
return ( return (
<Card className={className}> <Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@@ -288,9 +280,7 @@ export function StatCard({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{value}</div> <div className="text-2xl font-bold">{value}</div>
{description && ( {description && <p className="text-xs text-muted-foreground">{description}</p>}
<p className="text-xs text-muted-foreground">{description}</p>
)}
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -302,10 +292,11 @@ export function StatCard({
value="1,234" value="1,234"
description="+12% from last month" description="+12% from last month"
icon={<Users className="h-4 w-4 text-muted-foreground" />} icon={<Users className="h-4 w-4 text-muted-foreground" />}
/> />;
``` ```
**Key points:** **Key points:**
- Compose from shadcn/ui primitives - Compose from shadcn/ui primitives
- Keep structure consistent - Keep structure consistent
- Optional props with `?` - Optional props with `?`
@@ -354,14 +345,17 @@ export function Toggle({
} }
// Uncontrolled usage // Uncontrolled usage
<Toggle defaultValue={false}>Auto-save</Toggle> <Toggle defaultValue={false}>Auto-save</Toggle>;
// Controlled usage // Controlled usage
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
<Toggle value={enabled} onChange={setEnabled}>Auto-save</Toggle> <Toggle value={enabled} onChange={setEnabled}>
Auto-save
</Toggle>;
``` ```
**Key points:** **Key points:**
- Support both controlled and uncontrolled modes - Support both controlled and uncontrolled modes
- Use `defaultValue` for initial uncontrolled value - Use `defaultValue` for initial uncontrolled value
- Use `value` + `onChange` for controlled mode - 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. **class-variance-authority** (CVA) is a utility for creating component variants with Tailwind CSS.
**Why use CVA?** **Why use CVA?**
- ✅ Type-safe variant props - ✅ Type-safe variant props
- ✅ Compound variants (combinations) - ✅ Compound variants (combinations)
- ✅ Default variants - ✅ Default variants
@@ -390,24 +385,23 @@ import { cva } from 'class-variance-authority';
const alertVariants = cva( const alertVariants = cva(
// Base classes (always applied) // Base classes (always applied)
"relative w-full rounded-lg border p-4", 'relative w-full rounded-lg border p-4',
{ {
variants: { variants: {
variant: { variant: {
default: "bg-background text-foreground", default: 'bg-background text-foreground',
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
}, },
} }
); );
// Usage // Usage
<div className={alertVariants({ variant: "destructive" })}> <div className={alertVariants({ variant: 'destructive' })}>Alert content</div>;
Alert content
</div>
``` ```
--- ---
@@ -416,32 +410,32 @@ const alertVariants = cva(
```tsx ```tsx
const buttonVariants = cva( 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: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: "border border-input bg-background hover:bg-accent", outline: 'border border-input bg-background hover:bg-accent',
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: 'hover:bg-accent hover:text-accent-foreground',
}, },
size: { size: {
sm: "h-8 px-3 text-xs", sm: 'h-8 px-3 text-xs',
default: "h-10 px-4 text-sm", default: 'h-10 px-4 text-sm',
lg: "h-12 px-6 text-base", lg: 'h-12 px-6 text-base',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} }
); );
// Usage // Usage
<button className={buttonVariants({ variant: "outline", size: "lg" })}> <button className={buttonVariants({ variant: 'outline', size: 'lg' })}>
Large Outline Button Large Outline Button
</button> </button>;
``` ```
--- ---
@@ -451,28 +445,28 @@ const buttonVariants = cva(
**Use case**: Different classes when specific variant combinations are used **Use case**: Different classes when specific variant combinations are used
```tsx ```tsx
const buttonVariants = cva("base-classes", { const buttonVariants = cva('base-classes', {
variants: { variants: {
variant: { variant: {
default: "bg-primary", default: 'bg-primary',
destructive: "bg-destructive", destructive: 'bg-destructive',
}, },
size: { size: {
sm: "h-8", sm: 'h-8',
lg: "h-12", lg: 'h-12',
}, },
}, },
// Compound variants: specific combinations // Compound variants: specific combinations
compoundVariants: [ compoundVariants: [
{ {
variant: "destructive", variant: 'destructive',
size: "lg", size: 'lg',
class: "text-lg font-bold", // Applied when BOTH are true class: 'text-lg font-bold', // Applied when BOTH are true
}, },
], ],
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "sm", size: 'sm',
}, },
}); });
``` ```
@@ -484,6 +478,7 @@ const buttonVariants = cva("base-classes", {
### Prop Naming Conventions ### Prop Naming Conventions
**DO**: **DO**:
```tsx ```tsx
// ✅ Descriptive, semantic names // ✅ Descriptive, semantic names
interface UserCardProps { interface UserCardProps {
@@ -495,6 +490,7 @@ interface UserCardProps {
``` ```
**DON'T**: **DON'T**:
```tsx ```tsx
// ❌ Generic, unclear names // ❌ Generic, unclear names
interface CardProps { interface CardProps {
@@ -510,6 +506,7 @@ interface CardProps {
### Required vs Optional Props ### Required vs Optional Props
**Guidelines:** **Guidelines:**
- Required: Core functionality depends on it - Required: Core functionality depends on it
- Optional: Nice-to-have, has sensible default - Optional: Nice-to-have, has sensible default
@@ -531,7 +528,7 @@ interface AlertProps {
export function Alert({ export function Alert({
children, children,
variant = 'default', // Default for optional prop variant = 'default', // Default for optional prop
onClose, onClose,
icon, icon,
className, className,
@@ -545,6 +542,7 @@ export function Alert({
### Prop Type Patterns ### Prop Type Patterns
**Enum props** (limited options): **Enum props** (limited options):
```tsx ```tsx
interface ButtonProps { interface ButtonProps {
variant: 'default' | 'destructive' | 'outline'; variant: 'default' | 'destructive' | 'outline';
@@ -553,6 +551,7 @@ interface ButtonProps {
``` ```
**Boolean flags**: **Boolean flags**:
```tsx ```tsx
interface CardProps { interface CardProps {
isLoading?: boolean; isLoading?: boolean;
@@ -562,6 +561,7 @@ interface CardProps {
``` ```
**Callback props**: **Callback props**:
```tsx ```tsx
interface FormProps { interface FormProps {
onSubmit: (data: FormData) => void; onSubmit: (data: FormData) => void;
@@ -571,6 +571,7 @@ interface FormProps {
``` ```
**Render props** (advanced customization): **Render props** (advanced customization):
```tsx ```tsx
interface ListProps<T> { interface ListProps<T> {
items: T[]; items: T[];
@@ -583,7 +584,7 @@ interface ListProps<T> {
items={users} items={users}
renderItem={(user, i) => <UserCard key={i} user={user} />} renderItem={(user, i) => <UserCard key={i} user={user} />}
renderEmpty={() => <EmptyState />} renderEmpty={() => <EmptyState />}
/> />;
``` ```
--- ---
@@ -593,6 +594,7 @@ interface ListProps<T> {
Before shipping a custom component, verify: Before shipping a custom component, verify:
### Visual Testing ### Visual Testing
- [ ] **Light mode** - Component looks correct - [ ] **Light mode** - Component looks correct
- [ ] **Dark mode** - Component looks correct (toggle theme) - [ ] **Dark mode** - Component looks correct (toggle theme)
- [ ] **All variants** - Test each variant works - [ ] **All variants** - Test each variant works
@@ -602,6 +604,7 @@ Before shipping a custom component, verify:
- [ ] **Empty state** - Handles no data gracefully - [ ] **Empty state** - Handles no data gracefully
### Accessibility Testing ### Accessibility Testing
- [ ] **Keyboard navigation** - Can be focused and activated with Tab/Enter - [ ] **Keyboard navigation** - Can be focused and activated with Tab/Enter
- [ ] **Focus indicators** - Visible focus ring (`:focus-visible`) - [ ] **Focus indicators** - Visible focus ring (`:focus-visible`)
- [ ] **Screen reader** - ARIA labels and roles present - [ ] **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.) - [ ] **Semantic HTML** - Using correct HTML elements (button, nav, etc.)
### Functional Testing ### Functional Testing
- [ ] **Props work** - All props apply correctly - [ ] **Props work** - All props apply correctly
- [ ] **className override** - Can override styles with className prop - [ ] **className override** - Can override styles with className prop
- [ ] **Controlled/uncontrolled** - Both modes work (if applicable) - [ ] **Controlled/uncontrolled** - Both modes work (if applicable)
@@ -616,6 +620,7 @@ Before shipping a custom component, verify:
- [ ] **TypeScript** - No type errors, props autocomplete - [ ] **TypeScript** - No type errors, props autocomplete
### Code Quality ### Code Quality
- [ ] **No console errors** - Check browser console - [ ] **No console errors** - Check browser console
- [ ] **No warnings** - React warnings, a11y warnings - [ ] **No warnings** - React warnings, a11y warnings
- [ ] **Performance** - No unnecessary re-renders - [ ] **Performance** - No unnecessary re-renders
@@ -644,13 +649,7 @@ interface StatCardProps {
className?: string; className?: string;
} }
export function StatCard({ export function StatCard({ title, value, change, icon: Icon, className }: StatCardProps) {
title,
value,
change,
icon: Icon,
className,
}: StatCardProps) {
return ( return (
<Card className={className}> <Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@@ -660,11 +659,9 @@ export function StatCard({
<CardContent> <CardContent>
<div className="text-2xl font-bold">{value}</div> <div className="text-2xl font-bold">{value}</div>
{change !== undefined && ( {change !== undefined && (
<p className={cn( <p className={cn('text-xs', change >= 0 ? 'text-green-600' : 'text-destructive')}>
"text-xs", {change >= 0 ? '+' : ''}
change >= 0 ? "text-green-600" : "text-destructive" {change}% from last month
)}>
{change >= 0 ? '+' : ''}{change}% from last month
</p> </p>
)} )}
</CardContent> </CardContent>
@@ -678,10 +675,11 @@ export function StatCard({
<StatCard title="Subscriptions" value="+2350" change={12.5} icon={Users} /> <StatCard title="Subscriptions" value="+2350" change={12.5} icon={Users} />
<StatCard title="Sales" value="+12,234" change={19} icon={CreditCard} /> <StatCard title="Sales" value="+12,234" change={19} icon={CreditCard} />
<StatCard title="Active Now" value="+573" change={-2.1} icon={Activity} /> <StatCard title="Active Now" value="+573" change={-2.1} icon={Activity} />
</div> </div>;
``` ```
**Why this works:** **Why this works:**
- Specific purpose (dashboard metrics) - Specific purpose (dashboard metrics)
- Reused 8+ times - Reused 8+ times
- Consistent structure - Consistent structure
@@ -747,18 +745,10 @@ export function ConfirmDialog({
<DialogDescription>{description}</DialogDescription> <DialogDescription>{description}</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
{cancelLabel} {cancelLabel}
</Button> </Button>
<Button <Button variant={variant} onClick={handleConfirm} disabled={isLoading}>
variant={variant}
onClick={handleConfirm}
disabled={isLoading}
>
{isLoading ? 'Processing...' : confirmLabel} {isLoading ? 'Processing...' : confirmLabel}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -781,10 +771,11 @@ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
await deleteUser(user.id); await deleteUser(user.id);
toast.success('User deleted'); toast.success('User deleted');
}} }}
/> />;
``` ```
**Why this works:** **Why this works:**
- Common pattern (confirmations) - Common pattern (confirmations)
- Handles loading states automatically - Handles loading states automatically
- Consistent UX across app - Consistent UX across app
@@ -808,19 +799,12 @@ interface PageHeaderProps {
className?: string; className?: string;
} }
export function PageHeader({ export function PageHeader({ title, description, action, className }: PageHeaderProps) {
title,
description,
action,
className,
}: PageHeaderProps) {
return ( return (
<div className={cn("flex items-center justify-between", className)}> <div className={cn('flex items-center justify-between', className)}>
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">{title}</h1> <h1 className="text-3xl font-bold tracking-tight">{title}</h1>
{description && ( {description && <p className="text-muted-foreground">{description}</p>}
<p className="text-muted-foreground">{description}</p>
)}
</div> </div>
{action && <div>{action}</div>} {action && <div>{action}</div>}
</div> </div>
@@ -837,7 +821,7 @@ export function PageHeader({
Create User Create User
</Button> </Button>
} }
/> />;
``` ```
--- ---
@@ -866,6 +850,7 @@ Before creating a custom component, ask:
--- ---
**Related Documentation:** **Related Documentation:**
- [Components](./02-components.md) - shadcn/ui component library - [Components](./02-components.md) - shadcn/ui component library
- [AI Guidelines](./08-ai-guidelines.md) - Component templates for AI - [AI Guidelines](./08-ai-guidelines.md) - Component templates for AI
- [Forms](./06-forms.md) - Form component patterns - [Forms](./06-forms.md) - Form component patterns

View File

@@ -27,6 +27,7 @@
- **shadcn/ui components** - Input, Label, Button, etc. - **shadcn/ui components** - Input, Label, Button, etc.
**Why this stack?** **Why this stack?**
- ✅ Type-safe validation (TypeScript + Zod) - ✅ Type-safe validation (TypeScript + Zod)
- ✅ Minimal re-renders (react-hook-form) - ✅ Minimal re-renders (react-hook-form)
- ✅ Accessible by default (shadcn/ui) - ✅ Accessible by default (shadcn/ui)
@@ -80,11 +81,7 @@ export function SimpleForm() {
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input <Input id="email" type="email" {...form.register('email')} />
id="email"
type="email"
{...form.register('email')}
/>
</div> </div>
<Button type="submit">Submit</Button> <Button type="submit">Submit</Button>
@@ -180,6 +177,7 @@ export function LoginForm() {
``` ```
**Key points:** **Key points:**
1. Define Zod schema first 1. Define Zod schema first
2. Infer TypeScript type with `z.infer` 2. Infer TypeScript type with `z.infer`
3. Use `zodResolver` in `useForm` 3. Use `zodResolver` in `useForm`
@@ -217,15 +215,9 @@ export function LoginForm() {
```tsx ```tsx
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">Description</Label>
<Textarea <Textarea id="description" rows={4} {...form.register('description')} />
id="description"
rows={4}
{...form.register('description')}
/>
{form.formState.errors.description && ( {form.formState.errors.description && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">{form.formState.errors.description.message}</p>
{form.formState.errors.description.message}
</p>
)} )}
</div> </div>
``` ```
@@ -237,10 +229,7 @@ export function LoginForm() {
```tsx ```tsx
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="role">Role</Label> <Label htmlFor="role">Role</Label>
<Select <Select value={form.watch('role')} onValueChange={(value) => form.setValue('role', value)}>
value={form.watch('role')}
onValueChange={(value) => form.setValue('role', value)}
>
<SelectTrigger id="role"> <SelectTrigger id="role">
<SelectValue placeholder="Select a role" /> <SelectValue placeholder="Select a role" />
</SelectTrigger> </SelectTrigger>
@@ -251,9 +240,7 @@ export function LoginForm() {
</SelectContent> </SelectContent>
</Select> </Select>
{form.formState.errors.role && ( {form.formState.errors.role && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">{form.formState.errors.role.message}</p>
{form.formState.errors.role.message}
</p>
)} )}
</div> </div>
``` ```
@@ -272,12 +259,12 @@ export function LoginForm() {
<Label htmlFor="terms" className="text-sm font-normal"> <Label htmlFor="terms" className="text-sm font-normal">
I accept the terms and conditions I accept the terms and conditions
</Label> </Label>
</div> </div>;
{form.formState.errors.acceptTerms && ( {
<p className="text-sm text-destructive"> form.formState.errors.acceptTerms && (
{form.formState.errors.acceptTerms.message} <p className="text-sm text-destructive">{form.formState.errors.acceptTerms.message}</p>
</p> );
)} }
``` ```
--- ---
@@ -289,22 +276,16 @@ export function LoginForm() {
<Label>Notification Method</Label> <Label>Notification Method</Label>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<input <input type="radio" id="email" value="email" {...form.register('notificationMethod')} />
type="radio" <Label htmlFor="email" className="font-normal">
id="email" Email
value="email" </Label>
{...form.register('notificationMethod')}
/>
<Label htmlFor="email" className="font-normal">Email</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<input <input type="radio" id="sms" value="sms" {...form.register('notificationMethod')} />
type="radio" <Label htmlFor="sms" className="font-normal">
id="sms" SMS
value="sms" </Label>
{...form.register('notificationMethod')}
/>
<Label htmlFor="sms" className="font-normal">SMS</Label>
</div> </div>
</div> </div>
</div> </div>
@@ -320,65 +301,68 @@ export function LoginForm() {
import { z } from 'zod'; import { z } from 'zod';
// Email // Email
z.string().email('Invalid email address') z.string().email('Invalid email address');
// Min/max length // 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 // Required field
z.string().min(1, 'This field is required') z.string().min(1, 'This field is required');
// Optional field // Optional field
z.string().optional() z.string().optional();
// Number with range // Number with range
z.number().min(0).max(100) z.number().min(0).max(100);
// Number from string input // Number from string input
z.coerce.number().min(0) z.coerce.number().min(0);
// Enum // Enum
z.enum(['admin', 'user', 'guest'], { z.enum(['admin', 'user', 'guest'], {
errorMap: () => ({ message: 'Invalid role' }) errorMap: () => ({ message: 'Invalid role' }),
}) });
// URL // URL
z.string().url('Invalid URL') z.string().url('Invalid URL');
// Password with requirements // Password with requirements
z.string() z.string()
.min(8, 'Password must be at least 8 characters') .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 uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase 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 // Confirm password
z.object({ z.object({
password: z.string().min(8), password: z.string().min(8),
confirmPassword: z.string() confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, { }).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match", message: "Passwords don't match",
path: ["confirmPassword"], path: ['confirmPassword'],
}) });
// Custom validation // Custom validation
z.string().refine((val) => !val.includes('badword'), { z.string().refine((val) => !val.includes('badword'), {
message: 'Invalid input', message: 'Invalid input',
}) });
// Conditional fields // Conditional fields
z.object({ z.object({
role: z.enum(['admin', 'user']), role: z.enum(['admin', 'user']),
adminKey: z.string().optional(), adminKey: z.string().optional(),
}).refine((data) => { }).refine(
if (data.role === 'admin') { (data) => {
return !!data.adminKey; 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:** **Accessibility notes:**
- Use `aria-invalid` to indicate error state - Use `aria-invalid` to indicate error state
- Use `aria-describedby` to link error message - Use `aria-describedby` to link error message
- Error ID format: `{fieldName}-error` - Error ID format: `{fieldName}-error`
@@ -470,14 +455,14 @@ const onSubmit = async (data: FormData) => {
}; };
// Display form-level error // Display form-level error
{form.formState.errors.root && ( {
<Alert variant="destructive"> form.formState.errors.root && (
<AlertCircle className="h-4 w-4" /> <Alert variant="destructive">
<AlertDescription> <AlertCircle className="h-4 w-4" />
{form.formState.errors.root.message} <AlertDescription>{form.formState.errors.root.message}</AlertDescription>
</AlertDescription> </Alert>
</Alert> );
)} }
``` ```
--- ---
@@ -620,32 +605,26 @@ const onSubmit = async (data: FormData) => {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h3 className="text-lg font-semibold">Personal Information</h3> <h3 className="text-lg font-semibold">Personal Information</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Basic details about you</p>
Basic details about you
</p>
</div> </div>
<Separator /> <Separator />
<div className="space-y-4"> <div className="space-y-4">{/* Fields */}</div>
{/* Fields */}
</div>
</div> </div>
{/* Section 2 */} {/* Section 2 */}
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h3 className="text-lg font-semibold">Account Settings</h3> <h3 className="text-lg font-semibold">Account Settings</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Configure your account preferences</p>
Configure your account preferences
</p>
</div> </div>
<Separator /> <Separator />
<div className="space-y-4"> <div className="space-y-4">{/* Fields */}</div>
{/* Fields */}
</div>
</div> </div>
<div className="flex justify-end gap-4"> <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> <Button type="submit">Save Changes</Button>
</div> </div>
</form> </form>
@@ -661,10 +640,14 @@ const onSubmit = async (data: FormData) => {
import { useFieldArray } from 'react-hook-form'; import { useFieldArray } from 'react-hook-form';
const schema = z.object({ const schema = z.object({
items: z.array(z.object({ items: z
name: z.string().min(1), .array(
quantity: z.coerce.number().min(1), z.object({
})).min(1, 'At least one item required'), name: z.string().min(1),
quantity: z.coerce.number().min(1),
})
)
.min(1, 'At least one item required'),
}); });
function DynamicForm() { function DynamicForm() {
@@ -684,30 +667,19 @@ function DynamicForm() {
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{fields.map((field, index) => ( {fields.map((field, index) => (
<div key={field.id} className="flex gap-4"> <div key={field.id} className="flex gap-4">
<Input <Input {...form.register(`items.${index}.name`)} placeholder="Item name" />
{...form.register(`items.${index}.name`)}
placeholder="Item name"
/>
<Input <Input
type="number" type="number"
{...form.register(`items.${index}.quantity`)} {...form.register(`items.${index}.quantity`)}
placeholder="Quantity" placeholder="Quantity"
/> />
<Button <Button type="button" variant="destructive" onClick={() => remove(index)}>
type="button"
variant="destructive"
onClick={() => remove(index)}
>
Remove Remove
</Button> </Button>
</div> </div>
))} ))}
<Button <Button type="button" variant="outline" onClick={() => append({ name: '', quantity: 1 })}>
type="button"
variant="outline"
onClick={() => append({ name: '', quantity: 1 })}
>
Add Item Add Item
</Button> </Button>
@@ -722,18 +694,23 @@ function DynamicForm() {
### Conditional Fields ### Conditional Fields
```tsx ```tsx
const schema = z.object({ const schema = z
role: z.enum(['user', 'admin']), .object({
adminKey: z.string().optional(), role: z.enum(['user', 'admin']),
}).refine((data) => { adminKey: z.string().optional(),
if (data.role === 'admin') { })
return !!data.adminKey; .refine(
} (data) => {
return true; if (data.role === 'admin') {
}, { return !!data.adminKey;
message: 'Admin key required', }
path: ['adminKey'], return true;
}); },
{
message: 'Admin key required',
path: ['adminKey'],
}
);
function ConditionalForm() { function ConditionalForm() {
const form = useForm({ resolver: zodResolver(schema) }); const form = useForm({ resolver: zodResolver(schema) });
@@ -741,23 +718,17 @@ function ConditionalForm() {
return ( return (
<form className="space-y-4"> <form className="space-y-4">
<Select <Select value={role} onValueChange={(val) => form.setValue('role', val as any)}>
value={role} <SelectTrigger>
onValueChange={(val) => form.setValue('role', val as any)} <SelectValue />
> </SelectTrigger>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="user">User</SelectItem> <SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem> <SelectItem value="admin">Admin</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{role === 'admin' && ( {role === 'admin' && <Input {...form.register('adminKey')} placeholder="Admin Key" />}
<Input
{...form.register('adminKey')}
placeholder="Admin Key"
/>
)}
</form> </form>
); );
} }
@@ -774,14 +745,10 @@ const schema = z.object({
}), }),
}); });
<input <input type="file" {...form.register('file')} accept="image/*" />;
type="file"
{...form.register('file')}
accept="image/*"
/>
const onSubmit = (data: FormData) => { const onSubmit = (data: FormData) => {
const file = data.file[0]; // FileList -> File const file = data.file[0]; // FileList -> File
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
// Upload formData // Upload formData
@@ -795,6 +762,7 @@ const onSubmit = (data: FormData) => {
Before shipping a form, verify: Before shipping a form, verify:
### Functionality ### Functionality
- [ ] All fields register correctly - [ ] All fields register correctly
- [ ] Validation works (test invalid inputs) - [ ] Validation works (test invalid inputs)
- [ ] Submit handler fires - [ ] Submit handler fires
@@ -803,6 +771,7 @@ Before shipping a form, verify:
- [ ] Success case redirects/shows success - [ ] Success case redirects/shows success
### Accessibility ### Accessibility
- [ ] Labels associated with inputs (`htmlFor` + `id`) - [ ] Labels associated with inputs (`htmlFor` + `id`)
- [ ] Error messages use `aria-describedby` - [ ] Error messages use `aria-describedby`
- [ ] Invalid inputs have `aria-invalid` - [ ] Invalid inputs have `aria-invalid`
@@ -810,6 +779,7 @@ Before shipping a form, verify:
- [ ] Submit button disabled during submission - [ ] Submit button disabled during submission
### UX ### UX
- [ ] Field errors appear on blur or submit - [ ] Field errors appear on blur or submit
- [ ] Loading state prevents double-submit - [ ] Loading state prevents double-submit
- [ ] Success message or redirect on success - [ ] Success message or redirect on success
@@ -827,11 +797,13 @@ Before shipping a form, verify:
--- ---
**Related Documentation:** **Related Documentation:**
- [Components](./02-components.md) - Input, Label, Button, Select - [Components](./02-components.md) - Input, Label, Button, Select
- [Layouts](./03-layouts.md) - Form layout patterns - [Layouts](./03-layouts.md) - Form layout patterns
- [Accessibility](./07-accessibility.md) - ARIA attributes for forms - [Accessibility](./07-accessibility.md) - ARIA attributes for forms
**External Resources:** **External Resources:**
- [react-hook-form Documentation](https://react-hook-form.com) - [react-hook-form Documentation](https://react-hook-form.com)
- [Zod Documentation](https://zod.dev) - [Zod Documentation](https://zod.dev)

View File

@@ -24,12 +24,14 @@
We follow **WCAG 2.1 Level AA** as the **minimum** standard. We follow **WCAG 2.1 Level AA** as the **minimum** standard.
**Why Level AA?** **Why Level AA?**
- ✅ Required for most legal compliance (ADA, Section 508) - ✅ Required for most legal compliance (ADA, Section 508)
- ✅ Covers 95%+ of accessibility needs - ✅ Covers 95%+ of accessibility needs
- ✅ Achievable without major UX compromises - ✅ Achievable without major UX compromises
- ✅ Industry standard for modern web apps - ✅ Industry standard for modern web apps
**WCAG Principles (POUR):** **WCAG Principles (POUR):**
1. **Perceivable** - Information can be perceived by users 1. **Perceivable** - Information can be perceived by users
2. **Operable** - Interface can be operated by users 2. **Operable** - Interface can be operated by users
3. **Understandable** - Information and operation are understandable 3. **Understandable** - Information and operation are understandable
@@ -63,14 +65,15 @@ Creating a UI element?
### Minimum Contrast Ratios (WCAG AA) ### Minimum Contrast Ratios (WCAG AA)
| Content Type | Minimum Ratio | Example | | Content Type | Minimum Ratio | Example |
|--------------|---------------|---------| | -------------------------------------- | ------------- | ---------------------------- |
| **Normal text** (< 18px) | **4.5:1** | Body paragraphs, form labels | | **Normal text** (< 18px) | **4.5:1** | Body paragraphs, form labels |
| **Large text** (≥ 18px or ≥ 14px bold) | **3:1** | Headings, subheadings | | **Large text** (≥ 18px or ≥ 14px bold) | **3:1** | Headings, subheadings |
| **UI components** | **3:1** | Buttons, form borders, icons | | **UI components** | **3:1** | Buttons, form borders, icons |
| **Graphical objects** | **3:1** | Chart elements, infographics | | **Graphical objects** | **3:1** | Chart elements, infographics |
**WCAG AAA (ideal, not required):** **WCAG AAA (ideal, not required):**
- Normal text: 7:1 - Normal text: 7:1
- Large text: 4.5:1 - Large text: 4.5:1
@@ -79,6 +82,7 @@ Creating a UI element?
### Testing Color Contrast ### Testing Color Contrast
**Tools:** **Tools:**
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) - [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- Chrome DevTools: Inspect element → Accessibility panel - Chrome DevTools: Inspect element → Accessibility panel
- [Contrast Ratio Tool](https://contrast-ratio.com) - [Contrast Ratio Tool](https://contrast-ratio.com)
@@ -104,6 +108,7 @@ Creating a UI element?
``` ```
**Our design system tokens are WCAG AA compliant:** **Our design system tokens are WCAG AA compliant:**
- `text-foreground` on `bg-background`: 12.6:1 ✅ - `text-foreground` on `bg-background`: 12.6:1 ✅
- `text-primary-foreground` on `bg-primary`: 8.2:1 ✅ - `text-primary-foreground` on `bg-primary`: 8.2:1 ✅
- `text-destructive` on `bg-background`: 5.1: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. **8% of men and 0.5% of women** have some form of color blindness.
**Best practices:** **Best practices:**
- ❌ Don't rely on color alone to convey information - ❌ Don't rely on color alone to convey information
- ✅ Use icons, text labels, or patterns in addition to color - ✅ Use icons, text labels, or patterns in addition to color
- ✅ Test with color blindness simulators - ✅ Test with color blindness simulators
@@ -148,6 +154,7 @@ Creating a UI element?
### Core Requirements ### Core Requirements
All interactive elements must be: All interactive elements must be:
1.**Focusable** - Can be reached with Tab key 1.**Focusable** - Can be reached with Tab key
2.**Activatable** - Can be triggered with Enter or Space 2.**Activatable** - Can be triggered with Enter or Space
3.**Navigable** - Can move between with arrow keys (where appropriate) 3.**Navigable** - Can move between with arrow keys (where appropriate)
@@ -176,6 +183,7 @@ All interactive elements must be:
``` ```
**When to use `tabIndex`:** **When to use `tabIndex`:**
- `tabIndex={0}` - Make non-interactive element focusable - `tabIndex={0}` - Make non-interactive element focusable
- `tabIndex={-1}` - Remove from tab order (for programmatic focus) - `tabIndex={-1}` - Remove from tab order (for programmatic focus)
- `tabIndex={1+}` - ❌ **Avoid** - Breaks natural order - `tabIndex={1+}` - ❌ **Avoid** - Breaks natural order
@@ -184,31 +192,31 @@ All interactive elements must be:
### Keyboard Shortcuts ### Keyboard Shortcuts
| Key | Action | Example | | Key | Action | Example |
|-----|--------|---------| | --------------- | ------------------------- | ----------------------------- |
| **Tab** | Move focus forward | Navigate through form fields | | **Tab** | Move focus forward | Navigate through form fields |
| **Shift + Tab** | Move focus backward | Go back to previous field | | **Shift + Tab** | Move focus backward | Go back to previous field |
| **Enter** | Activate button/link | Submit form, follow link | | **Enter** | Activate button/link | Submit form, follow link |
| **Space** | Activate button/checkbox | Toggle checkbox, click button | | **Space** | Activate button/checkbox | Toggle checkbox, click button |
| **Escape** | Close overlay | Close dialog, dropdown | | **Escape** | Close overlay | Close dialog, dropdown |
| **Arrow keys** | Navigate within component | Navigate dropdown items | | **Arrow keys** | Navigate within component | Navigate dropdown items |
| **Home** | Jump to start | First item in list | | **Home** | Jump to start | First item in list |
| **End** | Jump to end | Last item in list | | **End** | Jump to end | Last item in list |
--- ---
### Implementing Keyboard Navigation ### Implementing Keyboard Navigation
**Button (automatic):** **Button (automatic):**
```tsx ```tsx
// ✅ Button is keyboard accessible by default // ✅ Button is keyboard accessible by default
<Button onClick={handleClick}> <Button onClick={handleClick}>Click me</Button>
Click me
</Button>
// Enter or Space triggers onClick // Enter or Space triggers onClick
``` ```
**Custom clickable div (needs work):** **Custom clickable div (needs work):**
```tsx ```tsx
// ❌ BAD - Not keyboard accessible // ❌ BAD - Not keyboard accessible
<div onClick={handleClick}> <div onClick={handleClick}>
@@ -237,11 +245,12 @@ All interactive elements must be:
``` ```
**Dropdown navigation:** **Dropdown navigation:**
```tsx ```tsx
<DropdownMenu> <DropdownMenu>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem>Edit</DropdownMenuItem> {/* Arrow down */} <DropdownMenuItem>Edit</DropdownMenuItem> {/* Arrow down */}
<DropdownMenuItem>Delete</DropdownMenuItem> {/* Arrow down */} <DropdownMenuItem>Delete</DropdownMenuItem> {/* Arrow down */}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
// shadcn/ui handles arrow key navigation automatically // shadcn/ui handles arrow key navigation automatically
@@ -276,12 +285,14 @@ All interactive elements must be:
### Screen Reader Basics ### Screen Reader Basics
**Popular screen readers:** **Popular screen readers:**
- **NVDA** (Windows) - Free, most popular for testing - **NVDA** (Windows) - Free, most popular for testing
- **JAWS** (Windows) - Industry standard, paid - **JAWS** (Windows) - Industry standard, paid
- **VoiceOver** (macOS/iOS) - Built-in to Apple devices - **VoiceOver** (macOS/iOS) - Built-in to Apple devices
- **TalkBack** (Android) - Built-in to Android - **TalkBack** (Android) - Built-in to Android
**What screen readers announce:** **What screen readers announce:**
- Semantic element type (button, link, heading, etc.) - Semantic element type (button, link, heading, etc.)
- Element text content - Element text content
- Element state (expanded, selected, disabled) - Element state (expanded, selected, disabled)
@@ -334,6 +345,7 @@ All interactive elements must be:
``` ```
**Semantic elements:** **Semantic elements:**
- `<header>` - Page header - `<header>` - Page header
- `<nav>` - Navigation - `<nav>` - Navigation
- `<main>` - Main content (only one per page) - `<main>` - Main content (only one per page)
@@ -364,6 +376,7 @@ All interactive elements must be:
``` ```
**Icon-only buttons:** **Icon-only buttons:**
```tsx ```tsx
// ✅ GOOD - ARIA label // ✅ GOOD - ARIA label
<Button size="icon" aria-label="Close dialog"> <Button size="icon" aria-label="Close dialog">
@@ -383,6 +396,7 @@ All interactive elements must be:
### Common ARIA Attributes ### Common ARIA Attributes
**ARIA roles:** **ARIA roles:**
```tsx ```tsx
<div role="button" tabIndex={0}>Custom Button</div> <div role="button" tabIndex={0}>Custom Button</div>
<div role="alert">Error message</div> <div role="alert">Error message</div>
@@ -391,6 +405,7 @@ All interactive elements must be:
``` ```
**ARIA states:** **ARIA states:**
```tsx ```tsx
<button aria-expanded={isOpen}>Toggle Menu</button> <button aria-expanded={isOpen}>Toggle Menu</button>
<button aria-pressed={isActive}>Toggle</button> <button aria-pressed={isActive}>Toggle</button>
@@ -399,6 +414,7 @@ All interactive elements must be:
``` ```
**ARIA properties:** **ARIA properties:**
```tsx ```tsx
<button aria-label="Close">×</button> <button aria-label="Close">×</button>
<input aria-describedby="email-help" /> <input aria-describedby="email-help" />
@@ -412,6 +428,7 @@ All interactive elements must be:
### Form Accessibility ### Form Accessibility
**Label association:** **Label association:**
```tsx ```tsx
// ✅ GOOD - Explicit association // ✅ GOOD - Explicit association
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
@@ -423,6 +440,7 @@ All interactive elements must be:
``` ```
**Error messages:** **Error messages:**
```tsx ```tsx
// ✅ GOOD - Linked with aria-describedby // ✅ GOOD - Linked with aria-describedby
<Label htmlFor="password">Password</Label> <Label htmlFor="password">Password</Label>
@@ -444,6 +462,7 @@ All interactive elements must be:
``` ```
**Required fields:** **Required fields:**
```tsx ```tsx
// ✅ GOOD - Marked as required // ✅ GOOD - Marked as required
<Label htmlFor="name"> <Label htmlFor="name">
@@ -502,6 +521,7 @@ toast.success('User created');
``` ```
**Use `:focus-visible` instead of `:focus`:** **Use `:focus-visible` instead of `:focus`:**
- `:focus` - Shows on mouse click AND keyboard - `:focus` - Shows on mouse click AND keyboard
- `:focus-visible` - Shows only on keyboard (better UX) - `:focus-visible` - Shows only on keyboard (better UX)
@@ -516,7 +536,7 @@ toast.success('User created');
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent> <DialogContent>
{/* Focus trapped inside */} {/* Focus trapped inside */}
<Input autoFocus /> {/* Focus first field */} <Input autoFocus /> {/* Focus first field */}
<Button>Submit</Button> <Button>Submit</Button>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -539,7 +559,7 @@ const handleDelete = () => {
inputRef.current?.focus(); inputRef.current?.focus();
}; };
<Input ref={inputRef} /> <Input ref={inputRef} />;
``` ```
--- ---
@@ -549,11 +569,13 @@ const handleDelete = () => {
### Automated Testing Tools ### Automated Testing Tools
**Browser extensions:** **Browser extensions:**
- [axe DevTools](https://www.deque.com/axe/devtools/) - Free, comprehensive - [axe DevTools](https://www.deque.com/axe/devtools/) - Free, comprehensive
- [WAVE](https://wave.webaim.org/extension/) - Visual feedback - [WAVE](https://wave.webaim.org/extension/) - Visual feedback
- [Lighthouse](https://developer.chrome.com/docs/lighthouse/) - Built into Chrome - [Lighthouse](https://developer.chrome.com/docs/lighthouse/) - Built into Chrome
**CI/CD testing:** **CI/CD testing:**
- [@axe-core/react](https://github.com/dequelabs/axe-core-npm/tree/develop/packages/react) - Runtime accessibility 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 - [jest-axe](https://github.com/nickcolley/jest-axe) - Jest integration
- [Playwright accessibility testing](https://playwright.dev/docs/accessibility-testing) - [Playwright accessibility testing](https://playwright.dev/docs/accessibility-testing)
@@ -563,6 +585,7 @@ const handleDelete = () => {
### Manual Testing Checklist ### Manual Testing Checklist
#### Keyboard Testing #### Keyboard Testing
1. [ ] Unplug mouse 1. [ ] Unplug mouse
2. [ ] Tab through entire page 2. [ ] Tab through entire page
3. [ ] All interactive elements focusable? 3. [ ] All interactive elements focusable?
@@ -572,6 +595,7 @@ const handleDelete = () => {
7. [ ] Tab order logical? 7. [ ] Tab order logical?
#### Screen Reader Testing #### Screen Reader Testing
1. [ ] Install NVDA (Windows) or VoiceOver (Mac) 1. [ ] Install NVDA (Windows) or VoiceOver (Mac)
2. [ ] Navigate page with screen reader on 2. [ ] Navigate page with screen reader on
3. [ ] All content announced? 3. [ ] All content announced?
@@ -580,6 +604,7 @@ const handleDelete = () => {
6. [ ] Heading hierarchy correct? 6. [ ] Heading hierarchy correct?
#### Contrast Testing #### Contrast Testing
1. [ ] Use contrast checker on all text 1. [ ] Use contrast checker on all text
2. [ ] Check UI components (buttons, borders) 2. [ ] Check UI components (buttons, borders)
3. [ ] Test in dark mode too 3. [ ] Test in dark mode too
@@ -590,6 +615,7 @@ const handleDelete = () => {
### Testing with Real Users ### Testing with Real Users
**Considerations:** **Considerations:**
- Test with actual users who rely on assistive technologies - Test with actual users who rely on assistive technologies
- Different screen readers behave differently - Different screen readers behave differently
- Mobile screen readers (VoiceOver, TalkBack) differ from desktop - Mobile screen readers (VoiceOver, TalkBack) differ from desktop
@@ -600,6 +626,7 @@ const handleDelete = () => {
## Accessibility Checklist ## Accessibility Checklist
### General ### General
- [ ] Page has `<title>` and `<meta name="description">` - [ ] Page has `<title>` and `<meta name="description">`
- [ ] Page has proper heading hierarchy (h1 → h2 → h3) - [ ] Page has proper heading hierarchy (h1 → h2 → h3)
- [ ] Landmarks used (`<header>`, `<nav>`, `<main>`, `<footer>`) - [ ] Landmarks used (`<header>`, `<nav>`, `<main>`, `<footer>`)
@@ -607,12 +634,14 @@ const handleDelete = () => {
- [ ] No content relies on color alone - [ ] No content relies on color alone
### Color & Contrast ### Color & Contrast
- [ ] Text has 4.5:1 contrast (normal) or 3:1 (large) - [ ] Text has 4.5:1 contrast (normal) or 3:1 (large)
- [ ] UI components have 3:1 contrast - [ ] UI components have 3:1 contrast
- [ ] Tested in both light and dark modes - [ ] Tested in both light and dark modes
- [ ] Color blindness simulator used - [ ] Color blindness simulator used
### Keyboard ### Keyboard
- [ ] All interactive elements focusable - [ ] All interactive elements focusable
- [ ] Focus indicators visible (ring, outline, etc.) - [ ] Focus indicators visible (ring, outline, etc.)
- [ ] Tab order is logical - [ ] Tab order is logical
@@ -622,6 +651,7 @@ const handleDelete = () => {
- [ ] Arrow keys navigate lists/menus - [ ] Arrow keys navigate lists/menus
### Screen Readers ### Screen Readers
- [ ] All images have alt text - [ ] All images have alt text
- [ ] Icon-only buttons have aria-label - [ ] Icon-only buttons have aria-label
- [ ] Form labels associated with inputs - [ ] Form labels associated with inputs
@@ -631,6 +661,7 @@ const handleDelete = () => {
- [ ] ARIA roles used correctly - [ ] ARIA roles used correctly
### Forms ### Forms
- [ ] Labels associated with inputs (`htmlFor` + `id`) - [ ] Labels associated with inputs (`htmlFor` + `id`)
- [ ] Error messages linked (`aria-describedby`) - [ ] Error messages linked (`aria-describedby`)
- [ ] Invalid inputs marked (`aria-invalid`) - [ ] Invalid inputs marked (`aria-invalid`)
@@ -638,6 +669,7 @@ const handleDelete = () => {
- [ ] Submit button disabled during submission - [ ] Submit button disabled during submission
### Focus Management ### Focus Management
- [ ] Dialogs trap focus - [ ] Dialogs trap focus
- [ ] Focus returns after dialog closes - [ ] Focus returns after dialog closes
- [ ] Programmatic focus after actions - [ ] Programmatic focus after actions
@@ -650,29 +682,36 @@ const handleDelete = () => {
**Easy improvements with big impact:** **Easy improvements with big impact:**
1. **Add alt text to images** 1. **Add alt text to images**
```tsx ```tsx
<img src="/logo.png" alt="Company Logo" /> <img src="/logo.png" alt="Company Logo" />
``` ```
2. **Associate labels with inputs** 2. **Associate labels with inputs**
```tsx ```tsx
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input id="email" /> <Input id="email" />
``` ```
3. **Use semantic HTML** 3. **Use semantic HTML**
```tsx ```tsx
<button> instead of <div onClick> <button> instead of <div onClick>
``` ```
4. **Add aria-label to icon buttons** 4. **Add aria-label to icon buttons**
```tsx ```tsx
<Button aria-label="Close"><X /></Button> <Button aria-label="Close">
<X />
</Button>
``` ```
5. **Use semantic color tokens** 5. **Use semantic color tokens**
```tsx ```tsx
className="text-foreground" // Auto contrast className = 'text-foreground'; // Auto contrast
``` ```
6. **Test with keyboard only** 6. **Test with keyboard only**
@@ -691,11 +730,13 @@ const handleDelete = () => {
--- ---
**Related Documentation:** **Related Documentation:**
- [Forms](./06-forms.md) - Accessible form patterns - [Forms](./06-forms.md) - Accessible form patterns
- [Components](./02-components.md) - All components are accessible - [Components](./02-components.md) - All components are accessible
- [Foundations](./01-foundations.md) - Color contrast tokens - [Foundations](./01-foundations.md) - Color contrast tokens
**External Resources:** **External Resources:**
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/) - [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) - [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [A11y Project Checklist](https://www.a11yproject.com/checklist/) - [A11y Project Checklist](https://www.a11yproject.com/checklist/)

View File

@@ -9,19 +9,22 @@
### ALWAYS Do ### ALWAYS Do
1.**Import from `@/components/ui/*`** 1.**Import from `@/components/ui/*`**
```tsx ```tsx
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
``` ```
2. ✅ **Use semantic color tokens** 2. ✅ **Use semantic color tokens**
```tsx ```tsx
className="bg-primary text-primary-foreground" className = 'bg-primary text-primary-foreground';
className="text-destructive" className = 'text-destructive';
className="bg-muted text-muted-foreground" className = 'bg-muted text-muted-foreground';
``` ```
3. ✅ **Use `cn()` utility for className merging** 3. ✅ **Use `cn()` utility for className merging**
```tsx ```tsx
import { cn } from '@/lib/utils'; 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) 4. ✅ **Follow spacing scale** (multiples of 4: 0, 1, 2, 3, 4, 6, 8, 12, 16)
```tsx ```tsx
className="p-4 space-y-6 mb-8" className = 'p-4 space-y-6 mb-8';
``` ```
5. ✅ **Add accessibility attributes** 5. ✅ **Add accessibility attributes**
```tsx ```tsx
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input <Input
@@ -44,12 +49,14 @@
``` ```
6. ✅ **Use component variants** 6. ✅ **Use component variants**
```tsx ```tsx
<Button variant="destructive">Delete</Button> <Button variant="destructive">Delete</Button>
<Alert variant="destructive">Error message</Alert> <Alert variant="destructive">Error message</Alert>
``` ```
7. ✅ **Compose from shadcn/ui primitives** 7. ✅ **Compose from shadcn/ui primitives**
```tsx ```tsx
// Don't create custom card components // Don't create custom card components
// Use Card + CardHeader + CardTitle + CardContent // Use Card + CardHeader + CardTitle + CardContent
@@ -57,8 +64,8 @@
8. ✅ **Use mobile-first responsive design** 8. ✅ **Use mobile-first responsive design**
```tsx ```tsx
className="text-2xl sm:text-3xl lg:text-4xl" className = 'text-2xl sm:text-3xl lg:text-4xl';
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3" className = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3';
``` ```
--- ---
@@ -66,24 +73,27 @@
### NEVER Do ### NEVER Do
1. ❌ **NO arbitrary colors** 1. ❌ **NO arbitrary colors**
```tsx ```tsx
// ❌ WRONG // ❌ WRONG
className="bg-blue-500 text-white" className = 'bg-blue-500 text-white';
// ✅ CORRECT // ✅ CORRECT
className="bg-primary text-primary-foreground" className = 'bg-primary text-primary-foreground';
``` ```
2. ❌ **NO arbitrary spacing values** 2. ❌ **NO arbitrary spacing values**
```tsx ```tsx
// ❌ WRONG // ❌ WRONG
className="p-[13px] mb-[17px]" className = 'p-[13px] mb-[17px]';
// ✅ CORRECT // ✅ CORRECT
className="p-4 mb-4" className = 'p-4 mb-4';
``` ```
3. ❌ **NO inline styles** 3. ❌ **NO inline styles**
```tsx ```tsx
// ❌ WRONG // ❌ WRONG
style={{ margin: '10px', color: '#3b82f6' }} style={{ margin: '10px', color: '#3b82f6' }}
@@ -93,6 +103,7 @@
``` ```
4. ❌ **NO custom CSS classes** (use Tailwind utilities) 4. ❌ **NO custom CSS classes** (use Tailwind utilities)
```tsx ```tsx
// ❌ WRONG // ❌ WRONG
<div className="my-custom-class"> <div className="my-custom-class">
@@ -102,6 +113,7 @@
``` ```
5. ❌ **NO mixing component libraries** 5. ❌ **NO mixing component libraries**
```tsx ```tsx
// ❌ WRONG - Don't use Material-UI, Ant Design, etc. // ❌ WRONG - Don't use Material-UI, Ant Design, etc.
import { Button } from '@mui/material'; import { Button } from '@mui/material';
@@ -111,6 +123,7 @@
``` ```
6. ❌ **NO skipping accessibility** 6. ❌ **NO skipping accessibility**
```tsx ```tsx
// ❌ WRONG // ❌ WRONG
<button><X /></button> <button><X /></button>
@@ -122,6 +135,7 @@
``` ```
7. ❌ **NO creating custom variants without CVA** 7. ❌ **NO creating custom variants without CVA**
```tsx ```tsx
// ❌ WRONG // ❌ WRONG
<Button className={type === 'danger' ? 'bg-red-500' : 'bg-blue-500'}> <Button className={type === 'danger' ? 'bg-red-500' : 'bg-blue-500'}>
@@ -138,9 +152,7 @@
```tsx ```tsx
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">{/* Content */}</div>
{/* Content */}
</div>
</div> </div>
``` ```
@@ -148,7 +160,9 @@
```tsx ```tsx
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <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> </div>
``` ```
@@ -160,9 +174,7 @@
<CardTitle>Form Title</CardTitle> <CardTitle>Form Title</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form className="space-y-4"> <form className="space-y-4">{/* Form fields */}</form>
{/* Form fields */}
</form>
</CardContent> </CardContent>
</Card> </Card>
``` ```
@@ -170,9 +182,7 @@
### Centered Content ### Centered Content
```tsx ```tsx
<div className="max-w-2xl mx-auto px-4"> <div className="max-w-2xl mx-auto px-4">{/* Readable content width */}</div>
{/* Readable content width */}
</div>
``` ```
--- ---
@@ -191,17 +201,15 @@ interface MyComponentProps {
children: React.ReactNode; children: React.ReactNode;
} }
export function MyComponent({ export function MyComponent({ variant = 'default', className, children }: MyComponentProps) {
variant = 'default',
className,
children
}: MyComponentProps) {
return ( return (
<Card className={cn( <Card
"p-4", // base styles className={cn(
variant === 'compact' && "p-2", 'p-4', // base styles
className // allow overrides variant === 'compact' && 'p-2',
)}> className // allow overrides
)}
>
{children} {children}
</Card> </Card>
); );
@@ -215,22 +223,22 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const componentVariants = cva( const componentVariants = cva(
"base-classes-here", // base 'base-classes-here', // base
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground", default: 'bg-primary text-primary-foreground',
destructive: "bg-destructive text-destructive-foreground", destructive: 'bg-destructive text-destructive-foreground',
}, },
size: { size: {
sm: "h-8 px-3 text-xs", sm: 'h-8 px-3 text-xs',
default: "h-10 px-4 text-sm", default: 'h-10 px-4 text-sm',
lg: "h-12 px-6 text-base", lg: 'h-12 px-6 text-base',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} }
); );
@@ -240,9 +248,7 @@ interface ComponentProps
VariantProps<typeof componentVariants> {} VariantProps<typeof componentVariants> {}
export function Component({ variant, size, className, ...props }: ComponentProps) { export function Component({ variant, size, className, ...props }: ComponentProps) {
return ( return <div className={cn(componentVariants({ variant, size, className }))} {...props} />;
<div className={cn(componentVariants({ variant, size, className }))} {...props} />
);
} }
``` ```
@@ -313,18 +319,18 @@ export function MyForm() {
**Always use these semantic tokens:** **Always use these semantic tokens:**
| Token | Usage | | Token | Usage |
|-------|-------| | -------------------------------------------- | --------------------- |
| `bg-primary text-primary-foreground` | Primary buttons, CTAs | | `bg-primary text-primary-foreground` | Primary buttons, CTAs |
| `bg-secondary text-secondary-foreground` | Secondary actions | | `bg-secondary text-secondary-foreground` | Secondary actions |
| `bg-destructive text-destructive-foreground` | Delete, errors | | `bg-destructive text-destructive-foreground` | Delete, errors |
| `bg-muted text-muted-foreground` | Disabled states | | `bg-muted text-muted-foreground` | Disabled states |
| `bg-accent text-accent-foreground` | Hover states | | `bg-accent text-accent-foreground` | Hover states |
| `bg-card text-card-foreground` | Card backgrounds | | `bg-card text-card-foreground` | Card backgrounds |
| `text-foreground` | Body text | | `text-foreground` | Body text |
| `text-muted-foreground` | Secondary text | | `text-muted-foreground` | Secondary text |
| `border-border` | Borders | | `border-border` | Borders |
| `ring-ring` | Focus rings | | `ring-ring` | Focus rings |
--- ---
@@ -332,14 +338,14 @@ export function MyForm() {
**Use these spacing values (multiples of 4px):** **Use these spacing values (multiples of 4px):**
| Class | Value | Pixels | Usage | | Class | Value | Pixels | Usage |
|-------|-------|--------|-------| | ----- | ------ | ------ | ---------------- |
| `2` | 0.5rem | 8px | Tight spacing | | `2` | 0.5rem | 8px | Tight spacing |
| `4` | 1rem | 16px | Standard spacing | | `4` | 1rem | 16px | Standard spacing |
| `6` | 1.5rem | 24px | Section spacing | | `6` | 1.5rem | 24px | Section spacing |
| `8` | 2rem | 32px | Large gaps | | `8` | 2rem | 32px | Large gaps |
| `12` | 3rem | 48px | Section dividers | | `12` | 3rem | 48px | Section dividers |
| `16` | 4rem | 64px | Page sections | | `16` | 4rem | 64px | Page sections |
--- ---
@@ -428,7 +434,7 @@ function MyCard({ title, children }) {
<CardTitle>{title}</CardTitle> <CardTitle>{title}</CardTitle>
</CardHeader> </CardHeader>
<CardContent>{children}</CardContent> <CardContent>{children}</CardContent>
</Card> </Card>;
``` ```
### ❌ Mistake 5: Not using cn() utility ### ❌ Mistake 5: Not using cn() utility
@@ -497,7 +503,7 @@ Add to `.github/copilot-instructions.md`:
```markdown ```markdown
# Component Guidelines # Component Guidelines
- Import from @/components/ui/* - Import from @/components/ui/\*
- Use semantic colors: bg-primary, text-destructive - Use semantic colors: bg-primary, text-destructive
- Spacing: multiples of 4 (p-4, mb-6, gap-8) - Spacing: multiples of 4 (p-4, mb-6, gap-8)
- Use cn() for className merging - Use cn() for className merging
@@ -525,20 +531,20 @@ interface DashboardCardProps {
export function DashboardCard({ title, value, trend, className }: DashboardCardProps) { export function DashboardCard({ title, value, trend, className }: DashboardCardProps) {
return ( return (
<Card className={cn("p-6", className)}> <Card className={cn('p-6', className)}>
<CardHeader> <CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground"> <CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
{title}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{value}</div> <div className="text-2xl font-bold">{value}</div>
{trend && ( {trend && (
<p className={cn( <p
"text-xs", className={cn(
trend === 'up' && "text-green-600", 'text-xs',
trend === 'down' && "text-destructive" trend === 'up' && 'text-green-600',
)}> trend === 'down' && 'text-destructive'
)}
>
{trend === 'up' ? '↑' : '↓'} Trend {trend === 'up' ? '↑' : '↓'} Trend
</p> </p>
)} )}
@@ -549,6 +555,7 @@ export function DashboardCard({ title, value, trend, className }: DashboardCardP
``` ```
**Why it's good:** **Why it's good:**
- ✅ Imports from `@/components/ui/*` - ✅ Imports from `@/components/ui/*`
- ✅ Uses semantic tokens - ✅ Uses semantic tokens
- ✅ Uses `cn()` utility - ✅ Uses `cn()` utility

View File

@@ -20,21 +20,21 @@
### Semantic Colors ### Semantic Colors
| Token | Usage | Example | | Token | Usage | Example |
|-------|-------|---------| | -------------------------------------------- | ------------------------ | -------------------------- |
| `bg-primary text-primary-foreground` | CTAs, primary actions | Primary button | | `bg-primary text-primary-foreground` | CTAs, primary actions | Primary button |
| `bg-secondary text-secondary-foreground` | Secondary actions | Secondary button | | `bg-secondary text-secondary-foreground` | Secondary actions | Secondary button |
| `bg-destructive text-destructive-foreground` | Delete, errors | Delete button, error alert | | `bg-destructive text-destructive-foreground` | Delete, errors | Delete button, error alert |
| `bg-muted text-muted-foreground` | Disabled, subtle | Disabled button, TabsList | | `bg-muted text-muted-foreground` | Disabled, subtle | Disabled button, TabsList |
| `bg-accent text-accent-foreground` | Hover states | Dropdown hover | | `bg-accent text-accent-foreground` | Hover states | Dropdown hover |
| `bg-card text-card-foreground` | Cards, elevated surfaces | Card component | | `bg-card text-card-foreground` | Cards, elevated surfaces | Card component |
| `bg-popover text-popover-foreground` | Popovers, dropdowns | Dropdown content | | `bg-popover text-popover-foreground` | Popovers, dropdowns | Dropdown content |
| `bg-background text-foreground` | Page background | Body | | `bg-background text-foreground` | Page background | Body |
| `text-foreground` | Body text | Paragraphs | | `text-foreground` | Body text | Paragraphs |
| `text-muted-foreground` | Secondary text | Captions, helper text | | `text-muted-foreground` | Secondary text | Captions, helper text |
| `border-border` | Borders, dividers | Card borders, separators | | `border-border` | Borders, dividers | Card borders, separators |
| `border-input` | Input borders | Text input border | | `border-input` | Input borders | Text input border |
| `ring-ring` | Focus indicators | Focus ring | | `ring-ring` | Focus indicators | Focus ring |
### Usage Examples ### Usage Examples
@@ -61,29 +61,29 @@
### Font Sizes ### Font Sizes
| Class | rem | px | Use Case | Common | | Class | rem | px | Use Case | Common |
|-------|-----|----|----|:------:| | ----------- | -------- | ---- | ------------------------ | :----: |
| `text-xs` | 0.75rem | 12px | Labels, fine print | | | `text-xs` | 0.75rem | 12px | Labels, fine print | |
| `text-sm` | 0.875rem | 14px | Secondary text, captions | | | `text-sm` | 0.875rem | 14px | Secondary text, captions | |
| `text-base` | 1rem | 16px | Body text (default) | ⭐ | | `text-base` | 1rem | 16px | Body text (default) | ⭐ |
| `text-lg` | 1.125rem | 18px | Subheadings | | | `text-lg` | 1.125rem | 18px | Subheadings | |
| `text-xl` | 1.25rem | 20px | Card titles | ⭐ | | `text-xl` | 1.25rem | 20px | Card titles | ⭐ |
| `text-2xl` | 1.5rem | 24px | Section headings | ⭐ | | `text-2xl` | 1.5rem | 24px | Section headings | ⭐ |
| `text-3xl` | 1.875rem | 30px | Page titles | ⭐ | | `text-3xl` | 1.875rem | 30px | Page titles | ⭐ |
| `text-4xl` | 2.25rem | 36px | Large headings | | | `text-4xl` | 2.25rem | 36px | Large headings | |
| `text-5xl` | 3rem | 48px | Hero text | | | `text-5xl` | 3rem | 48px | Hero text | |
⭐ = Most commonly used ⭐ = Most commonly used
### Font Weights ### Font Weights
| Class | Value | Use Case | Common | | Class | Value | Use Case | Common |
|-------|-------|----------|:------:| | --------------- | ----- | -------------------- | :----: |
| `font-light` | 300 | De-emphasized text | | | `font-light` | 300 | De-emphasized text | |
| `font-normal` | 400 | Body text (default) | ⭐ | | `font-normal` | 400 | Body text (default) | ⭐ |
| `font-medium` | 500 | Labels, menu items | ⭐ | | `font-medium` | 500 | Labels, menu items | ⭐ |
| `font-semibold` | 600 | Subheadings, buttons | | | `font-semibold` | 600 | Subheadings, buttons | |
| `font-bold` | 700 | Headings, emphasis | ⭐ | | `font-bold` | 700 | Headings, emphasis | ⭐ |
⭐ = Most commonly used ⭐ = Most commonly used
@@ -115,35 +115,35 @@
### Spacing Values ### Spacing Values
| Token | rem | px | Use Case | Common | | Token | rem | px | Use Case | Common |
|-------|-----|----|----|:------:| | ----- | -------- | ---- | ------------------------------ | :----: |
| `0` | 0 | 0px | No spacing | | | `0` | 0 | 0px | No spacing | |
| `px` | - | 1px | Borders | | | `px` | - | 1px | Borders | |
| `0.5` | 0.125rem | 2px | Very tight | | | `0.5` | 0.125rem | 2px | Very tight | |
| `1` | 0.25rem | 4px | Icon gaps | | | `1` | 0.25rem | 4px | Icon gaps | |
| `2` | 0.5rem | 8px | Tight spacing (label → input) | ⭐ | | `2` | 0.5rem | 8px | Tight spacing (label → input) | ⭐ |
| `3` | 0.75rem | 12px | Component padding | | | `3` | 0.75rem | 12px | Component padding | |
| `4` | 1rem | 16px | Standard spacing (form fields) | | | `4` | 1rem | 16px | Standard spacing (form fields) | |
| `5` | 1.25rem | 20px | Medium spacing | | | `5` | 1.25rem | 20px | Medium spacing | |
| `6` | 1.5rem | 24px | Section spacing (cards) | ⭐ | | `6` | 1.5rem | 24px | Section spacing (cards) | ⭐ |
| `8` | 2rem | 32px | Large gaps | ⭐ | | `8` | 2rem | 32px | Large gaps | ⭐ |
| `10` | 2.5rem | 40px | Very large gaps | | | `10` | 2.5rem | 40px | Very large gaps | |
| `12` | 3rem | 48px | Section dividers | ⭐ | | `12` | 3rem | 48px | Section dividers | ⭐ |
| `16` | 4rem | 64px | Page sections | | | `16` | 4rem | 64px | Page sections | |
⭐ = Most commonly used ⭐ = Most commonly used
### Spacing Methods ### Spacing Methods
| Method | Use Case | Example | | Method | Use Case | Example |
|--------|----------|---------| | ----------- | ------------------------- | ------------ |
| `gap-4` | Flex/grid spacing | `flex gap-4` | | `gap-4` | Flex/grid spacing | `flex gap-4` |
| `space-y-4` | Vertical stack spacing | `space-y-4` | | `space-y-4` | Vertical stack spacing | `space-y-4` |
| `space-x-4` | Horizontal stack spacing | `space-x-4` | | `space-x-4` | Horizontal stack spacing | `space-x-4` |
| `p-4` | Padding (all sides) | `p-4` | | `p-4` | Padding (all sides) | `p-4` |
| `px-4` | Horizontal padding | `px-4` | | `px-4` | Horizontal padding | `px-4` |
| `py-4` | Vertical padding | `py-4` | | `py-4` | Vertical padding | `py-4` |
| `m-4` | Margin (exceptions only!) | `mt-8` | | `m-4` | Margin (exceptions only!) | `mt-8` |
### Common Spacing Patterns ### Common Spacing Patterns
@@ -217,52 +217,52 @@
```tsx ```tsx
// 1 → 2 → 3 progression (most common) // 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 // 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) // 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) // 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 ### Container Widths
```tsx ```tsx
// Standard container // Standard container
className="container mx-auto px-4" className = 'container mx-auto px-4';
// Constrained widths // Constrained widths
className="max-w-md mx-auto" // 448px - Forms className = 'max-w-md mx-auto'; // 448px - Forms
className="max-w-lg mx-auto" // 512px - Modals className = 'max-w-lg mx-auto'; // 512px - Modals
className="max-w-2xl mx-auto" // 672px - Articles className = 'max-w-2xl mx-auto'; // 672px - Articles
className="max-w-4xl mx-auto" // 896px - Wide layouts className = 'max-w-4xl mx-auto'; // 896px - Wide layouts
className="max-w-7xl mx-auto" // 1280px - Full page className = 'max-w-7xl mx-auto'; // 1280px - Full page
``` ```
### Flex Patterns ### Flex Patterns
```tsx ```tsx
// Horizontal flex // Horizontal flex
className="flex gap-4" className = 'flex gap-4';
// Vertical flex // Vertical flex
className="flex flex-col gap-4" className = 'flex flex-col gap-4';
// Center items // Center items
className="flex items-center justify-center" className = 'flex items-center justify-center';
// Space between // Space between
className="flex items-center justify-between" className = 'flex items-center justify-between';
// Wrap items // Wrap items
className="flex flex-wrap gap-4" className = 'flex flex-wrap gap-4';
// Responsive: stack on mobile, row on desktop // 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 ```tsx
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">{/* Content */}</div>
{/* Content */}
</div>
</div> </div>
``` ```
@@ -287,7 +285,9 @@ className="flex flex-col sm:flex-row gap-4"
<CardTitle>Title</CardTitle> <CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription> <CardDescription>Description</CardDescription>
</div> </div>
<Button variant="outline" size="sm">Action</Button> <Button variant="outline" size="sm">
Action
</Button>
</CardHeader> </CardHeader>
``` ```
@@ -319,9 +319,7 @@ className="flex flex-col sm:flex-row gap-4"
<CardTitle>Form Title</CardTitle> <CardTitle>Form Title</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form className="space-y-4"> <form className="space-y-4">{/* Fields */}</form>
{/* Fields */}
</form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -354,17 +352,13 @@ className="flex flex-col sm:flex-row gap-4"
### Responsive Text ### Responsive Text
```tsx ```tsx
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold"> <h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold">Responsive Title</h1>
Responsive Title
</h1>
``` ```
### Responsive Padding ### Responsive Padding
```tsx ```tsx
<div className="p-4 sm:p-6 lg:p-8"> <div className="p-4 sm:p-6 lg:p-8">Responsive padding</div>
Responsive padding
</div>
``` ```
--- ---
@@ -427,16 +421,16 @@ Has error?
## Keyboard Shortcuts ## Keyboard Shortcuts
| Key | Action | Context | | Key | Action | Context |
|-----|--------|---------| | ------------- | ------------------------ | ------------------- |
| `Tab` | Move focus forward | All | | `Tab` | Move focus forward | All |
| `Shift + Tab` | Move focus backward | All | | `Shift + Tab` | Move focus backward | All |
| `Enter` | Activate button/link | Buttons, links | | `Enter` | Activate button/link | Buttons, links |
| `Space` | Activate button/checkbox | Buttons, checkboxes | | `Space` | Activate button/checkbox | Buttons, checkboxes |
| `Escape` | Close overlay | Dialogs, dropdowns | | `Escape` | Close overlay | Dialogs, dropdowns |
| `Arrow keys` | Navigate items | Dropdowns, lists | | `Arrow keys` | Navigate items | Dropdowns, lists |
| `Home` | Jump to start | Lists | | `Home` | Jump to start | Lists |
| `End` | Jump to end | Lists | | `End` | Jump to end | Lists |
--- ---
@@ -482,7 +476,13 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; 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'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
// Utilities // Utilities
@@ -506,53 +506,53 @@ import { Check, X, AlertCircle, Loader2 } from 'lucide-react';
```tsx ```tsx
// Required string // Required string
z.string().min(1, 'Required') z.string().min(1, 'Required');
// Email // Email
z.string().email('Invalid email') z.string().email('Invalid email');
// Min/max length // 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 // Optional
z.string().optional() z.string().optional();
// Number // Number
z.coerce.number().min(0).max(100) z.coerce.number().min(0).max(100);
// Enum // Enum
z.enum(['admin', 'user', 'guest']) z.enum(['admin', 'user', 'guest']);
// Boolean // Boolean
z.boolean().refine(val => val === true, { message: 'Must accept' }) z.boolean().refine((val) => val === true, { message: 'Must accept' });
// Password confirmation // Password confirmation
z.object({ z.object({
password: z.string().min(8), password: z.string().min(8),
confirmPassword: z.string() confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, { }).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match", message: "Passwords don't match",
path: ["confirmPassword"], path: ['confirmPassword'],
}) });
``` ```
--- ---
## Responsive Breakpoints ## Responsive Breakpoints
| Breakpoint | Min Width | Typical Device | | Breakpoint | Min Width | Typical Device |
|------------|-----------|----------------| | ---------- | --------- | --------------------------- |
| `sm:` | 640px | Large phones, small tablets | | `sm:` | 640px | Large phones, small tablets |
| `md:` | 768px | Tablets | | `md:` | 768px | Tablets |
| `lg:` | 1024px | Laptops, desktops | | `lg:` | 1024px | Laptops, desktops |
| `xl:` | 1280px | Large desktops | | `xl:` | 1280px | Large desktops |
| `2xl:` | 1536px | Extra large screens | | `2xl:` | 1536px | Extra large screens |
```tsx ```tsx
// Mobile-first (default → sm → md → lg) // Mobile-first (default → sm → md → lg)
className="text-sm sm:text-base md:text-lg lg:text-xl" className = 'text-sm sm:text-base md:text-lg lg:text-xl';
className="p-4 sm:p-6 lg:p-8" className = 'p-4 sm:p-6 lg:p-8';
className="grid-cols-1 md:grid-cols-2 lg:grid-cols-3" 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 ### Shadows
```tsx ```tsx
shadow-sm // Cards, panels shadow - sm; // Cards, panels
shadow-md // Dropdowns, tooltips shadow - md; // Dropdowns, tooltips
shadow-lg // Modals, popovers shadow - lg; // Modals, popovers
shadow-xl // Floating notifications shadow - xl; // Floating notifications
``` ```
### Border Radius ### Border Radius
```tsx ```tsx
rounded-sm // 2px - Tags, small badges rounded - sm; // 2px - Tags, small badges
rounded-md // 4px - Inputs, small buttons rounded - md; // 4px - Inputs, small buttons
rounded-lg // 6px - Cards, buttons (default) rounded - lg; // 6px - Cards, buttons (default)
rounded-xl // 10px - Large cards, modals rounded - xl; // 10px - Large cards, modals
rounded-full // Pills, avatars, icon buttons rounded - full; // Pills, avatars, icon buttons
``` ```
--- ---
@@ -589,6 +589,7 @@ rounded-full // Pills, avatars, icon buttons
--- ---
**Related Documentation:** **Related Documentation:**
- [Quick Start](./00-quick-start.md) - 5-minute crash course - [Quick Start](./00-quick-start.md) - 5-minute crash course
- [Foundations](./01-foundations.md) - Detailed color, typography, spacing - [Foundations](./01-foundations.md) - Detailed color, typography, spacing
- [Components](./02-components.md) - All component variants - [Components](./02-components.md) - All component variants

View File

@@ -10,7 +10,9 @@
## Project Overview ## Project Overview
### Vision ### Vision
Create a world-class design system documentation that: Create a world-class design system documentation that:
- Follows Pareto principle (80% coverage with 20% content) - Follows Pareto principle (80% coverage with 20% content)
- Includes AI-specific code generation guidelines - Includes AI-specific code generation guidelines
- Provides interactive, copy-paste examples - Provides interactive, copy-paste examples
@@ -18,6 +20,7 @@ Create a world-class design system documentation that:
- Maintains perfect internal coherence and link integrity - Maintains perfect internal coherence and link integrity
### Key Principles ### Key Principles
1. **Pareto-Efficiency** - 80/20 rule applied throughout 1. **Pareto-Efficiency** - 80/20 rule applied throughout
2. **AI-Optimized** - Dedicated guidelines for AI code generation 2. **AI-Optimized** - Dedicated guidelines for AI code generation
3. **Interconnected** - All docs cross-reference each other 3. **Interconnected** - All docs cross-reference each other
@@ -163,6 +166,7 @@ Create a world-class design system documentation that:
### Documentation Review & Fixes ✅ ### Documentation Review & Fixes ✅
#### Issues Found During Review: #### Issues Found During Review:
1. **Time estimates in section headers** - Removed all (user request) 1. **Time estimates in section headers** - Removed all (user request)
- Removed "⏱️ Time to productive: 5 minutes" from header - Removed "⏱️ Time to productive: 5 minutes" from header
- Removed "(3 minutes)", "(30 seconds)" from all section headers - 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 - Fixed: Added missing `SelectGroup` and `SelectLabel` to import statement in 02-components.md
#### Comprehensive Review Results: #### Comprehensive Review Results:
- **✅ 100+ links checked** - **✅ 100+ links checked**
- **✅ 0 broken internal doc links** - **✅ 0 broken internal doc links**
- **✅ 0 logic inconsistencies** - **✅ 0 logic inconsistencies**
@@ -200,7 +205,9 @@ Create a world-class design system documentation that:
## Phase 2: Interactive Demos (PENDING) ## Phase 2: Interactive Demos (PENDING)
### Objective ### Objective
Create live, interactive demonstration pages at `/dev/*` routes with: Create live, interactive demonstration pages at `/dev/*` routes with:
- Copy-paste ready code snippets - Copy-paste ready code snippets
- Before/after comparisons - Before/after comparisons
- Live component examples - Live component examples
@@ -272,12 +279,14 @@ Create live, interactive demonstration pages at `/dev/*` routes with:
### Overall Progress: 100% Complete ✅ ### Overall Progress: 100% Complete ✅
**Phase 1: Documentation** ✅ 100% (14/14 tasks) **Phase 1: Documentation** ✅ 100% (14/14 tasks)
- All documentation files created (~7,600 lines) - All documentation files created (~7,600 lines)
- All issues fixed (4 issues resolved) - All issues fixed (4 issues resolved)
- Comprehensive review completed (100+ links verified) - Comprehensive review completed (100+ links verified)
- CLAUDE.md updated - CLAUDE.md updated
**Phase 2: Interactive Demos** ✅ 100% (6/6 tasks) **Phase 2: Interactive Demos** ✅ 100% (6/6 tasks)
- Utility components created (~470 lines) - Utility components created (~470 lines)
- Hub page created (~220 lines) - Hub page created (~220 lines)
- All demo pages created and enhanced (~2,388 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 ### Technical Implementation
**Technologies Used:** **Technologies Used:**
- Next.js 15 App Router - Next.js 15 App Router
- React 19 + TypeScript - React 19 + TypeScript
- shadcn/ui components (all) - shadcn/ui components (all)
@@ -395,6 +405,7 @@ Create live, interactive demonstration pages at `/dev/*` routes with:
- Responsive design (mobile-first) - Responsive design (mobile-first)
**Architecture:** **Architecture:**
- Server components for static pages (hub, layouts, spacing) - Server components for static pages (hub, layouts, spacing)
- Client components for interactive pages (components, forms) - Client components for interactive pages (components, forms)
- Reusable utility components in `/src/components/dev/` - Reusable utility components in `/src/components/dev/`

View File

@@ -6,26 +6,28 @@
## 🚀 Quick Navigation ## 🚀 Quick Navigation
| For... | Start Here | Time | | For... | Start Here | Time |
|--------|-----------|------| | ------------------------- | ------------------------------------------------------------------------------------- | ------- |
| **Quick Start** | [⚡ 5-Minute Crash Course](./00-quick-start.md) | 5 min | | **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 | | **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 | | **Layout Design** | [📐 Layouts](./03-layouts.md) → [📏 Spacing](./04-spacing-philosophy.md) | 20 min |
| **AI Code Generation** | [🤖 AI Guidelines](./08-ai-guidelines.md) | 3 min | | **AI Code Generation** | [🤖 AI Guidelines](./08-ai-guidelines.md) | 3 min |
| **Quick Reference** | [📚 Reference Tables](./99-reference.md) | Instant | | **Quick Reference** | [📚 Reference Tables](./99-reference.md) | Instant |
| **Complete Guide** | Read all docs in order | 1 hour | | **Complete Guide** | Read all docs in order | 1 hour |
--- ---
## 📖 Documentation Structure ## 📖 Documentation Structure
### Getting Started ### Getting Started
- **[00. Quick Start](./00-quick-start.md)** ⚡ - **[00. Quick Start](./00-quick-start.md)** ⚡
- 5-minute crash course - 5-minute crash course
- Essential components and patterns - Essential components and patterns
- Copy-paste ready examples - Copy-paste ready examples
### Fundamentals ### Fundamentals
- **[01. Foundations](./01-foundations.md)** 🎨 - **[01. Foundations](./01-foundations.md)** 🎨
- Color system (OKLCH) - Color system (OKLCH)
- Typography scale - Typography scale
@@ -39,6 +41,7 @@
- Composition patterns - Composition patterns
### Layouts & Spacing ### Layouts & Spacing
- **[03. Layouts](./03-layouts.md)** 📐 - **[03. Layouts](./03-layouts.md)** 📐
- Grid vs Flex decision tree - Grid vs Flex decision tree
- Common layout patterns - Common layout patterns
@@ -52,6 +55,7 @@
- Consistency patterns - Consistency patterns
### Building Components ### Building Components
- **[05. Component Creation](./05-component-creation.md)** 🔨 - **[05. Component Creation](./05-component-creation.md)** 🔨
- When to create vs compose - When to create vs compose
- Component templates - Component templates
@@ -65,6 +69,7 @@
- Multi-field examples - Multi-field examples
### Best Practices ### Best Practices
- **[07. Accessibility](./07-accessibility.md)** ♿ - **[07. Accessibility](./07-accessibility.md)** ♿
- WCAG AA compliance - WCAG AA compliance
- Keyboard navigation - Keyboard navigation
@@ -78,6 +83,7 @@
- Component templates - Component templates
### Reference ### Reference
- **[99. Reference Tables](./99-reference.md)** 📚 - **[99. Reference Tables](./99-reference.md)** 📚
- Quick lookup tables - Quick lookup tables
- All tokens at a glance - All tokens at a glance
@@ -95,6 +101,7 @@ Explore live examples and copy-paste code:
- **[Form Patterns](/dev/forms)** - Complete form examples - **[Form Patterns](/dev/forms)** - Complete form examples
Each demo page includes: Each demo page includes:
- ✅ Live, interactive examples - ✅ Live, interactive examples
- ✅ Click-to-copy code snippets - ✅ Click-to-copy code snippets
- ✅ Before/after comparisons - ✅ Before/after comparisons
@@ -105,6 +112,7 @@ Each demo page includes:
## 🛤️ Learning Paths ## 🛤️ Learning Paths
### Path 1: Speedrun (5 minutes) ### Path 1: Speedrun (5 minutes)
**Goal**: Start building immediately **Goal**: Start building immediately
1. [Quick Start](./00-quick-start.md) - Essential patterns 1. [Quick Start](./00-quick-start.md) - Essential patterns
@@ -116,6 +124,7 @@ Each demo page includes:
--- ---
### Path 2: Component Developer (15 minutes) ### Path 2: Component Developer (15 minutes)
**Goal**: Master component building **Goal**: Master component building
1. [Quick Start](./00-quick-start.md) - Basics 1. [Quick Start](./00-quick-start.md) - Basics
@@ -128,6 +137,7 @@ Each demo page includes:
--- ---
### Path 3: Layout Specialist (20 minutes) ### Path 3: Layout Specialist (20 minutes)
**Goal**: Master layouts and spacing **Goal**: Master layouts and spacing
1. [Quick Start](./00-quick-start.md) - Basics 1. [Quick Start](./00-quick-start.md) - Basics
@@ -141,6 +151,7 @@ Each demo page includes:
--- ---
### Path 4: Form Specialist (15 minutes) ### Path 4: Form Specialist (15 minutes)
**Goal**: Master forms and validation **Goal**: Master forms and validation
1. [Quick Start](./00-quick-start.md) - Basics 1. [Quick Start](./00-quick-start.md) - Basics
@@ -154,6 +165,7 @@ Each demo page includes:
--- ---
### Path 5: AI Setup (3 minutes) ### Path 5: AI Setup (3 minutes)
**Goal**: Configure AI for perfect code generation **Goal**: Configure AI for perfect code generation
1. [AI Guidelines](./08-ai-guidelines.md) - Read once, code forever 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) ### Path 6: Comprehensive Mastery (1 hour)
**Goal**: Complete understanding of the design system **Goal**: Complete understanding of the design system
Read all documents in order: Read all documents in order:
1. [Quick Start](./00-quick-start.md) 1. [Quick Start](./00-quick-start.md)
2. [Foundations](./01-foundations.md) 2. [Foundations](./01-foundations.md)
3. [Components](./02-components.md) 3. [Components](./02-components.md)
@@ -211,18 +225,21 @@ Our design system is built on these core principles:
## 🤝 Contributing to the Design System ## 🤝 Contributing to the Design System
### Adding a New Component ### Adding a New Component
1. Read [Component Creation Guide](./05-component-creation.md) 1. Read [Component Creation Guide](./05-component-creation.md)
2. Follow the template 2. Follow the template
3. Add to [Component Showcase](/dev/components) 3. Add to [Component Showcase](/dev/components)
4. Document in [Components](./02-components.md) 4. Document in [Components](./02-components.md)
### Adding a New Pattern ### Adding a New Pattern
1. Validate it solves a real need (used 3+ times) 1. Validate it solves a real need (used 3+ times)
2. Document in appropriate guide 2. Document in appropriate guide
3. Add to [Reference](./99-reference.md) 3. Add to [Reference](./99-reference.md)
4. Create example in `/dev/` 4. Create example in `/dev/`
### Updating Colors/Tokens ### Updating Colors/Tokens
1. Edit `src/app/globals.css` 1. Edit `src/app/globals.css`
2. Test in both light and dark modes 2. Test in both light and dark modes
3. Verify WCAG AA contrast 3. Verify WCAG AA contrast

View File

@@ -63,6 +63,7 @@ npm run test:e2e -- --debug
**Test Results:** 34/43 passing (79% pass rate) **Test Results:** 34/43 passing (79% pass rate)
### Passing Tests ✅ ### Passing Tests ✅
- All AuthGuard tests (8/8) - All AuthGuard tests (8/8)
- Most Login tests (6/8) - Most Login tests (6/8)
- Most Registration tests (7/11) - Most Registration tests (7/11)
@@ -83,6 +84,7 @@ The 9 failing tests are due to minor validation message text mismatches between
### Recommendations ### Recommendations
These failures can be fixed by: These failures can be fixed by:
1. Inspecting the actual error messages rendered by forms 1. Inspecting the actual error messages rendered by forms
2. Updating test assertions to match exact wording 2. Updating test assertions to match exact wording
3. Adding more specific selectors to avoid strict mode violations 3. Adding more specific selectors to avoid strict mode violations
@@ -98,6 +100,7 @@ The core functionality is working - the failures are only assertion mismatches,
## Configuration ## Configuration
See `playwright.config.ts` for: See `playwright.config.ts` for:
- Browser targets (Chromium, Firefox, WebKit) - Browser targets (Chromium, Firefox, WebKit)
- Base URL configuration - Base URL configuration
- Screenshot and video settings - Screenshot and video settings

View File

@@ -4,10 +4,7 @@
*/ */
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { import { setupAuthenticatedMocks, setupSuperuserMocks } from './helpers/auth';
setupAuthenticatedMocks,
setupSuperuserMocks,
} from './helpers/auth';
test.describe('Admin Access Control', () => { test.describe('Admin Access Control', () => {
test('regular user should not see admin link in header', async ({ page }) => { test('regular user should not see admin link in header', async ({ page }) => {
@@ -25,9 +22,7 @@ test.describe('Admin Access Control', () => {
expect(visibleAdminLinks).toBe(0); expect(visibleAdminLinks).toBe(0);
}); });
test('regular user should be redirected when accessing admin page directly', async ({ test('regular user should be redirected when accessing admin page directly', async ({ page }) => {
page,
}) => {
// Set up mocks for regular user // Set up mocks for regular user
await setupAuthenticatedMocks(page); await setupAuthenticatedMocks(page);
// Auth already cached in storage state (loginViaUI removed for performance) // Auth already cached in storage state (loginViaUI removed for performance)
@@ -60,9 +55,7 @@ test.describe('Admin Access Control', () => {
await expect(headerAdminLink).toHaveAttribute('href', '/admin'); await expect(headerAdminLink).toHaveAttribute('href', '/admin');
}); });
test('superuser should be able to access admin dashboard', async ({ test('superuser should be able to access admin dashboard', async ({ page }) => {
page,
}) => {
// Set up mocks for superuser // Set up mocks for superuser
await setupSuperuserMocks(page); await setupSuperuserMocks(page);
// Auth already cached in storage state (loginViaUI removed for performance) // Auth already cached in storage state (loginViaUI removed for performance)
@@ -110,23 +103,15 @@ test.describe('Admin Dashboard', () => {
await expect(statTitles.filter({ hasText: 'Total Users' })).toBeVisible(); await expect(statTitles.filter({ hasText: 'Total Users' })).toBeVisible();
await expect(statTitles.filter({ hasText: 'Active Users' })).toBeVisible(); await expect(statTitles.filter({ hasText: 'Active Users' })).toBeVisible();
await expect(statTitles.filter({ hasText: 'Organizations' })).toBeVisible(); await expect(statTitles.filter({ hasText: 'Organizations' })).toBeVisible();
await expect( await expect(statTitles.filter({ hasText: 'Active Sessions' })).toBeVisible();
statTitles.filter({ hasText: 'Active Sessions' })
).toBeVisible();
}); });
test('should display quick action cards', async ({ page }) => { test('should display quick action cards', async ({ page }) => {
await expect( await expect(page.getByRole('heading', { name: 'Quick Actions', exact: true })).toBeVisible();
page.getByRole('heading', { name: 'Quick Actions', exact: true })
).toBeVisible();
// Should have three action cards (use unique descriptive text to avoid sidebar matches) // Should have three action cards (use unique descriptive text to avoid sidebar matches)
await expect( await expect(page.getByText('View, create, and manage user accounts')).toBeVisible();
page.getByText('View, create, and manage user accounts') await expect(page.getByText('Manage organizations and their members')).toBeVisible();
).toBeVisible();
await expect(
page.getByText('Manage organizations and their members')
).toBeVisible();
await expect(page.getByText('Configure system-wide settings')).toBeVisible(); await expect(page.getByText('Configure system-wide settings')).toBeVisible();
}); });
}); });
@@ -222,9 +207,7 @@ test.describe('Admin Navigation', () => {
await expect(page.getByText('Admin Panel')).toBeVisible(); await expect(page.getByText('Admin Panel')).toBeVisible();
}); });
test('should navigate back to dashboard from users page', async ({ test('should navigate back to dashboard from users page', async ({ page }) => {
page,
}) => {
await page.goto('/admin/users'); await page.goto('/admin/users');
// Click dashboard link in sidebar // Click dashboard link in sidebar
@@ -275,10 +258,7 @@ test.describe('Admin Breadcrumbs', () => {
// Click 'Admin' breadcrumb to go back to dashboard // Click 'Admin' breadcrumb to go back to dashboard
const adminBreadcrumb = page.getByTestId('breadcrumb-admin'); const adminBreadcrumb = page.getByTestId('breadcrumb-admin');
await Promise.all([ await Promise.all([page.waitForURL('/admin'), adminBreadcrumb.click()]);
page.waitForURL('/admin'),
adminBreadcrumb.click()
]);
await expect(page).toHaveURL('/admin'); await expect(page).toHaveURL('/admin');
}); });

View File

@@ -74,18 +74,19 @@ test.describe('Admin Dashboard - Quick Actions', () => {
const quickActionsSection = page.locator('h2:has-text("Quick Actions")').locator('..'); const quickActionsSection = page.locator('h2:has-text("Quick Actions")').locator('..');
// Use heading role to match only the card titles, not descriptions // Use heading role to match only the card titles, not descriptions
await expect(quickActionsSection.getByRole('heading', { name: 'User Management' })).toBeVisible(); await expect(
quickActionsSection.getByRole('heading', { name: 'User Management' })
).toBeVisible();
await expect(quickActionsSection.getByRole('heading', { name: 'Organizations' })).toBeVisible(); await expect(quickActionsSection.getByRole('heading', { name: 'Organizations' })).toBeVisible();
await expect(quickActionsSection.getByRole('heading', { name: 'System Settings' })).toBeVisible(); await expect(
quickActionsSection.getByRole('heading', { name: 'System Settings' })
).toBeVisible();
}); });
test('should navigate to users page when clicking user management', async ({ page }) => { test('should navigate to users page when clicking user management', async ({ page }) => {
const userManagementLink = page.getByRole('link', { name: /User Management/i }); const userManagementLink = page.getByRole('link', { name: /User Management/i });
await Promise.all([ await Promise.all([page.waitForURL('/admin/users'), userManagementLink.click()]);
page.waitForURL('/admin/users'),
userManagementLink.click()
]);
await expect(page).toHaveURL('/admin/users'); await expect(page).toHaveURL('/admin/users');
}); });
@@ -95,10 +96,7 @@ test.describe('Admin Dashboard - Quick Actions', () => {
const quickActionsSection = page.locator('h2:has-text("Quick Actions")').locator('..'); const quickActionsSection = page.locator('h2:has-text("Quick Actions")').locator('..');
const organizationsLink = quickActionsSection.getByRole('link', { name: /Organizations/i }); const organizationsLink = quickActionsSection.getByRole('link', { name: /Organizations/i });
await Promise.all([ await Promise.all([page.waitForURL('/admin/organizations'), organizationsLink.click()]);
page.waitForURL('/admin/organizations'),
organizationsLink.click()
]);
await expect(page).toHaveURL('/admin/organizations'); await expect(page).toHaveURL('/admin/organizations');
}); });

View File

@@ -15,7 +15,9 @@ test.describe('Admin Organization Members - Navigation from Organizations List',
await page.waitForSelector('table tbody tr'); await page.waitForSelector('table tbody tr');
}); });
test('should navigate to members page when clicking view members in action menu', async ({ page }) => { test('should navigate to members page when clicking view members in action menu', async ({
page,
}) => {
// Click first organization's action menu // Click first organization's action menu
const actionButton = page.getByRole('button', { name: /Actions for/i }).first(); const actionButton = page.getByRole('button', { name: /Actions for/i }).first();
await actionButton.click(); await actionButton.click();
@@ -23,7 +25,7 @@ test.describe('Admin Organization Members - Navigation from Organizations List',
// Click "View Members" // Click "View Members"
await Promise.all([ await Promise.all([
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/), page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
page.getByText('View Members').click() page.getByText('View Members').click(),
]); ]);
// Should be on members page // Should be on members page
@@ -38,7 +40,7 @@ test.describe('Admin Organization Members - Navigation from Organizations List',
// Click on member count // Click on member count
await Promise.all([ await Promise.all([
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/), page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
memberButton.click() memberButton.click(),
]); ]);
// Should be on members page // Should be on members page
@@ -59,7 +61,7 @@ test.describe('Admin Organization Members - Page Structure', () => {
await Promise.all([ await Promise.all([
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/), page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
page.getByText('View Members').click() page.getByText('View Members').click(),
]); ]);
}); });
@@ -74,7 +76,9 @@ test.describe('Admin Organization Members - Page Structure', () => {
}); });
test('should display page description', async ({ page }) => { test('should display page description', async ({ page }) => {
await expect(page.getByText('Manage members and their roles within the organization')).toBeVisible(); await expect(
page.getByText('Manage members and their roles within the organization')
).toBeVisible();
}); });
test('should display add member button', async ({ page }) => { test('should display add member button', async ({ page }) => {
@@ -87,7 +91,6 @@ test.describe('Admin Organization Members - Page Structure', () => {
await expect(backButton).toBeVisible(); await expect(backButton).toBeVisible();
}); });
test('should have proper heading hierarchy', async ({ page }) => { test('should have proper heading hierarchy', async ({ page }) => {
// Wait for page to load // Wait for page to load
await page.waitForSelector('table'); await page.waitForSelector('table');
@@ -129,7 +132,7 @@ test.describe('Admin Organization Members - AddMemberDialog E2E Tests', () => {
await Promise.all([ await Promise.all([
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/), page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
page.getByText('View Members').click() page.getByText('View Members').click(),
]); ]);
// Open Add Member dialog // Open Add Member dialog
@@ -150,7 +153,9 @@ test.describe('Admin Organization Members - AddMemberDialog E2E Tests', () => {
}); });
test('should display dialog description', async ({ page }) => { test('should display dialog description', async ({ page }) => {
await expect(page.getByText(/Add a user to this organization and assign them a role/i)).toBeVisible(); await expect(
page.getByText(/Add a user to this organization and assign them a role/i)
).toBeVisible();
}); });
test('should display user email select field', async ({ page }) => { test('should display user email select field', async ({ page }) => {

View File

@@ -193,7 +193,7 @@ test.describe('Admin Organization Management - Action Menu', () => {
// Click view members - use Promise.all for Next.js Link navigation // Click view members - use Promise.all for Next.js Link navigation
await Promise.all([ await Promise.all([
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/), page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
page.getByText('View Members').click() page.getByText('View Members').click(),
]); ]);
// Should navigate to members page // Should navigate to members page
@@ -220,7 +220,9 @@ test.describe('Admin Organization Management - Action Menu', () => {
await page.getByText('Delete Organization').click(); await page.getByText('Delete Organization').click();
// Warning should be shown // Warning should be shown
await expect(page.getByText(/This action cannot be undone and will remove all associated data/i)).toBeVisible(); await expect(
page.getByText(/This action cannot be undone and will remove all associated data/i)
).toBeVisible();
}); });
test('should close delete dialog when clicking cancel', async ({ page }) => { test('should close delete dialog when clicking cancel', async ({ page }) => {
@@ -307,7 +309,7 @@ test.describe('Admin Organization Management - Member Count Interaction', () =>
// Click on member count - use Promise.all for Next.js Link navigation // Click on member count - use Promise.all for Next.js Link navigation
await Promise.all([ await Promise.all([
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/), page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
memberButton.click() memberButton.click(),
]); ]);
// Should navigate to members page // Should navigate to members page

View File

@@ -132,10 +132,13 @@ test.describe('Admin User Management - Search and Filters', () => {
await searchInput.fill('admin'); await searchInput.fill('admin');
// Wait for debounce and URL to update // Wait for debounce and URL to update
await page.waitForFunction(() => { await page.waitForFunction(
const url = new URL(window.location.href); () => {
return url.searchParams.has('search'); const url = new URL(window.location.href);
}, { timeout: 2000 }); return url.searchParams.has('search');
},
{ timeout: 2000 }
);
// Check that URL contains search parameter // Check that URL contains search parameter
expect(page.url()).toContain('search=admin'); expect(page.url()).toContain('search=admin');
@@ -144,51 +147,66 @@ test.describe('Admin User Management - Search and Filters', () => {
// Note: Active status filter URL parameter behavior is tested in the unit tests // Note: Active status filter URL parameter behavior is tested in the unit tests
// (UserManagementContent.test.tsx). Skipping E2E test due to flaky URL timing. // (UserManagementContent.test.tsx). Skipping E2E test due to flaky URL timing.
test('should filter users by inactive status (adds active=false param to URL)', async ({ page }) => { test('should filter users by inactive status (adds active=false param to URL)', async ({
page,
}) => {
const statusFilter = page.getByRole('combobox').first(); const statusFilter = page.getByRole('combobox').first();
await statusFilter.click(); await statusFilter.click();
// Click on "Inactive" option and wait for URL update // Click on "Inactive" option and wait for URL update
await Promise.all([ await Promise.all([
page.waitForFunction(() => { page.waitForFunction(
const url = new URL(window.location.href); () => {
return url.searchParams.get('active') === 'false'; const url = new URL(window.location.href);
}, { timeout: 2000 }), return url.searchParams.get('active') === 'false';
page.getByRole('option', { name: 'Inactive' }).click() },
{ timeout: 2000 }
),
page.getByRole('option', { name: 'Inactive' }).click(),
]); ]);
// Check that URL contains active=false parameter // Check that URL contains active=false parameter
expect(page.url()).toContain('active=false'); expect(page.url()).toContain('active=false');
}); });
test('should filter users by superuser status (adds superuser param to URL)', async ({ page }) => { test('should filter users by superuser status (adds superuser param to URL)', async ({
page,
}) => {
const userTypeFilter = page.getByRole('combobox').nth(1); const userTypeFilter = page.getByRole('combobox').nth(1);
await userTypeFilter.click(); await userTypeFilter.click();
// Click on "Superusers" option and wait for URL update // Click on "Superusers" option and wait for URL update
await Promise.all([ await Promise.all([
page.waitForFunction(() => { page.waitForFunction(
const url = new URL(window.location.href); () => {
return url.searchParams.get('superuser') === 'true'; const url = new URL(window.location.href);
}, { timeout: 2000 }), return url.searchParams.get('superuser') === 'true';
page.getByRole('option', { name: 'Superusers' }).click() },
{ timeout: 2000 }
),
page.getByRole('option', { name: 'Superusers' }).click(),
]); ]);
// Check that URL contains superuser parameter // Check that URL contains superuser parameter
expect(page.url()).toContain('superuser=true'); expect(page.url()).toContain('superuser=true');
}); });
test('should filter users by regular user status (adds superuser=false param to URL)', async ({ page }) => { test('should filter users by regular user status (adds superuser=false param to URL)', async ({
page,
}) => {
const userTypeFilter = page.getByRole('combobox').nth(1); const userTypeFilter = page.getByRole('combobox').nth(1);
await userTypeFilter.click(); await userTypeFilter.click();
// Click on "Regular" option and wait for URL update // Click on "Regular" option and wait for URL update
await Promise.all([ await Promise.all([
page.waitForFunction(() => { page.waitForFunction(
const url = new URL(window.location.href); () => {
return url.searchParams.get('superuser') === 'false'; const url = new URL(window.location.href);
}, { timeout: 2000 }), return url.searchParams.get('superuser') === 'false';
page.getByRole('option', { name: 'Regular' }).click() },
{ timeout: 2000 }
),
page.getByRole('option', { name: 'Regular' }).click(),
]); ]);
// Check that URL contains superuser=false parameter // Check that URL contains superuser=false parameter
@@ -208,10 +226,13 @@ test.describe('Admin User Management - Search and Filters', () => {
const searchInput = page.getByPlaceholder(/Search by name or email/i); const searchInput = page.getByPlaceholder(/Search by name or email/i);
await searchInput.fill('test'); await searchInput.fill('test');
await page.waitForFunction(() => { await page.waitForFunction(
const url = new URL(window.location.href); () => {
return url.searchParams.has('search'); const url = new URL(window.location.href);
}, { timeout: 2000 }); return url.searchParams.has('search');
},
{ timeout: 2000 }
);
// URL should have page=1 or no page param (defaults to 1) // URL should have page=1 or no page param (defaults to 1)
const newUrl = page.url(); const newUrl = page.url();
@@ -502,9 +523,7 @@ test.describe('Admin User Management - Edit User Dialog', () => {
await page.getByText('Edit User').click(); await page.getByText('Edit User').click();
// Password field should indicate it's optional // Password field should indicate it's optional
await expect( await expect(page.getByLabel(/Password.*\(leave blank to keep current\)/i)).toBeVisible();
page.getByLabel(/Password.*\(leave blank to keep current\)/i)
).toBeVisible();
}); });
test('should have placeholder for password in edit mode', async ({ page }) => { test('should have placeholder for password in edit mode', async ({ page }) => {

View File

@@ -11,9 +11,7 @@ test.describe('AuthGuard - Route Protection', () => {
}); });
}); });
test('should redirect to login when accessing protected route without auth', async ({ test('should redirect to login when accessing protected route without auth', async ({ page }) => {
page,
}) => {
// Try to access a protected route (if you have one) // Try to access a protected route (if you have one)
// For now, we'll test the root if it's protected // For now, we'll test the root if it's protected
// Adjust the route based on your actual protected routes // Adjust the route based on your actual protected routes

View File

@@ -162,10 +162,7 @@ test.describe('Login Flow', () => {
// Click forgot password link - use Promise.all to wait for navigation // Click forgot password link - use Promise.all to wait for navigation
const forgotLink = page.getByRole('link', { name: 'Forgot password?' }); const forgotLink = page.getByRole('link', { name: 'Forgot password?' });
await Promise.all([ await Promise.all([page.waitForURL('/password-reset'), forgotLink.click()]);
page.waitForURL('/password-reset'),
forgotLink.click()
]);
// Should be on password reset page // Should be on password reset page
await expect(page).toHaveURL('/password-reset'); await expect(page).toHaveURL('/password-reset');
@@ -176,10 +173,7 @@ test.describe('Login Flow', () => {
// Click sign up link - use Promise.all to wait for navigation // Click sign up link - use Promise.all to wait for navigation
const signupLink = page.getByRole('link', { name: 'Sign up' }); const signupLink = page.getByRole('link', { name: 'Sign up' });
await Promise.all([ await Promise.all([page.waitForURL('/register'), signupLink.click()]);
page.waitForURL('/register'),
signupLink.click()
]);
// Should be on register page // Should be on register page
await expect(page).toHaveURL('/register'); await expect(page).toHaveURL('/register');

View File

@@ -55,10 +55,7 @@ test.describe('Password Reset Request Flow', () => {
// Click back to login link - use Promise.all to wait for navigation // Click back to login link - use Promise.all to wait for navigation
const loginLink = page.getByRole('link', { name: 'Back to login' }); const loginLink = page.getByRole('link', { name: 'Back to login' });
await Promise.all([ await Promise.all([page.waitForURL('/login'), loginLink.click()]);
page.waitForURL('/login', ),
loginLink.click()
]);
// Should be on login page // Should be on login page
await expect(page).toHaveURL('/login'); await expect(page).toHaveURL('/login');
@@ -196,10 +193,7 @@ test.describe('Password Reset Confirm Flow', () => {
// Click request new reset link - use Promise.all to wait for navigation // Click request new reset link - use Promise.all to wait for navigation
const resetLink = page.getByRole('link', { name: 'Request new reset link' }); const resetLink = page.getByRole('link', { name: 'Request new reset link' });
await Promise.all([ await Promise.all([page.waitForURL('/password-reset'), resetLink.click()]);
page.waitForURL('/password-reset', ),
resetLink.click()
]);
// Should be on password reset request page // Should be on password reset request page
await expect(page).toHaveURL('/password-reset'); await expect(page).toHaveURL('/password-reset');

View File

@@ -222,10 +222,7 @@ test.describe('Registration Flow', () => {
const loginLink = page.getByRole('link', { name: 'Sign in' }); const loginLink = page.getByRole('link', { name: 'Sign in' });
// Use Promise.all to wait for navigation // Use Promise.all to wait for navigation
await Promise.all([ await Promise.all([page.waitForURL('/login'), loginLink.click()]);
page.waitForURL('/login'),
loginLink.click()
]);
// Should be on login page // Should be on login page
await expect(page).toHaveURL('/login'); await expect(page).toHaveURL('/login');

View File

@@ -95,7 +95,11 @@ export const MOCK_ORGANIZATIONS = [
* @param email User email (defaults to mock user email) * @param email User email (defaults to mock user email)
* @param password User password (defaults to mock password) * @param password User password (defaults to mock password)
*/ */
export async function loginViaUI(page: Page, email = 'test@example.com', password = 'Password123!'): Promise<void> { export async function loginViaUI(
page: Page,
email = 'test@example.com',
password = 'Password123!'
): Promise<void> {
// Navigate to login page // Navigate to login page
await page.goto('/login'); await page.goto('/login');
@@ -104,10 +108,7 @@ export async function loginViaUI(page: Page, email = 'test@example.com', passwor
await page.locator('input[name="password"]').fill(password); await page.locator('input[name="password"]').fill(password);
// Submit and wait for navigation to home // Submit and wait for navigation to home
await Promise.all([ await Promise.all([page.waitForURL('/'), page.locator('button[type="submit"]').click()]);
page.waitForURL('/'),
page.locator('button[type="submit"]').click(),
]);
// Wait for auth to settle // Wait for auth to settle
await page.waitForTimeout(500); await page.waitForTimeout(500);
@@ -136,8 +137,10 @@ export async function setupAuthenticatedMocks(page: Page): Promise<void> {
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify({ body: JSON.stringify({
user: MOCK_USER, user: MOCK_USER,
access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDEiLCJleHAiOjk5OTk5OTk5OTl9.signature', access_token:
refresh_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDIiLCJleHAiOjk5OTk5OTk5OTl9.signature', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDEiLCJleHAiOjk5OTk5OTk5OTl9.signature',
refresh_token:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDIiLCJleHAiOjk5OTk5OTk5OTl9.signature',
expires_in: 3600, expires_in: 3600,
token_type: 'bearer', token_type: 'bearer',
}), }),
@@ -239,8 +242,10 @@ export async function setupSuperuserMocks(page: Page): Promise<void> {
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify({ body: JSON.stringify({
user: MOCK_SUPERUSER, user: MOCK_SUPERUSER,
access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDMiLCJleHAiOjk5OTk5OTk5OTl9.signature', access_token:
refresh_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDQiLCJleHAiOjk5OTk5OTk5OTl9.signature', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDMiLCJleHAiOjk5OTk5OTk5OTl9.signature',
refresh_token:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDQiLCJleHAiOjk5OTk5OTk5OTl9.signature',
expires_in: 3600, expires_in: 3600,
token_type: 'bearer', token_type: 'bearer',
}), }),
@@ -376,7 +381,7 @@ export async function setupSuperuserMocks(page: Page): Promise<void> {
if (route.request().method() === 'GET' && isSingleOrgEndpoint) { if (route.request().method() === 'GET' && isSingleOrgEndpoint) {
// Extract org ID from URL // Extract org ID from URL
const orgId = url.match(/organizations\/([^/]+)/)?.[1]?.replace(/\/$/, ''); // Remove trailing slash if any const orgId = url.match(/organizations\/([^/]+)/)?.[1]?.replace(/\/$/, ''); // Remove trailing slash if any
const org = MOCK_ORGANIZATIONS.find(o => o.id === orgId) || MOCK_ORGANIZATIONS[0]; const org = MOCK_ORGANIZATIONS.find((o) => o.id === orgId) || MOCK_ORGANIZATIONS[0];
await route.fulfill({ await route.fulfill({
status: 200, status: 200,

View File

@@ -55,7 +55,7 @@ export async function startCoverage(
} }
try { try {
await page.coverage.startJSCoverage({ await page.coverage.startJSCoverage({
resetOnNavigation: options?.resetOnNavigation ?? false, resetOnNavigation: options?.resetOnNavigation ?? false,
// @ts-expect-error - includeRawScriptCoverage is not in official types but supported by Playwright // @ts-expect-error - includeRawScriptCoverage is not in official types but supported by Playwright
includeRawScriptCoverage: options?.includeRawScriptCoverage ?? false, includeRawScriptCoverage: options?.includeRawScriptCoverage ?? false,
@@ -201,9 +201,9 @@ export const withCoverage = {
function sanitizeFilename(name: string): string { function sanitizeFilename(name: string): string {
return name return name
.replace(/[^a-z0-9\s-]/gi, '') // Remove special chars .replace(/[^a-z0-9\s-]/gi, '') // Remove special chars
.replace(/\s+/g, '_') // Replace spaces with underscores .replace(/\s+/g, '_') // Replace spaces with underscores
.toLowerCase() .toLowerCase()
.substring(0, 100); // Limit length .substring(0, 100); // Limit length
} }
/** /**

View File

@@ -70,16 +70,16 @@ test.describe('Homepage - Desktop Navigation', () => {
const header = page.locator('header').first(); const header = page.locator('header').first();
const headerLoginLink = header.getByRole('link', { name: /^Login$/i }); const headerLoginLink = header.getByRole('link', { name: /^Login$/i });
await Promise.all([ await Promise.all([page.waitForURL('/login'), headerLoginLink.click()]);
page.waitForURL('/login'),
headerLoginLink.click()
]);
await expect(page).toHaveURL('/login'); await expect(page).toHaveURL('/login');
}); });
test.skip('should open demo credentials modal when clicking Try Demo', async ({ page }) => { test.skip('should open demo credentials modal when clicking Try Demo', async ({ page }) => {
await page.getByRole('button', { name: /Try Demo/i }).first().click(); await page
.getByRole('button', { name: /Try Demo/i })
.first()
.click();
// Dialog should be visible (wait longer for React to render with animations) // Dialog should be visible (wait longer for React to render with animations)
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
@@ -204,10 +204,7 @@ test.describe('Homepage - Mobile Menu Interactions', () => {
const loginLink = mobileMenu.getByRole('link', { name: /Login/i }); const loginLink = mobileMenu.getByRole('link', { name: /Login/i });
await loginLink.waitFor({ state: 'visible' }); await loginLink.waitFor({ state: 'visible' });
await Promise.all([ await Promise.all([page.waitForURL('/login'), loginLink.click()]);
page.waitForURL('/login'),
loginLink.click()
]);
await expect(page).toHaveURL('/login'); await expect(page).toHaveURL('/login');
}); });
@@ -230,7 +227,9 @@ test.describe('Homepage - Hero Section', () => {
}); });
test('should display main headline', async ({ page }) => { test('should display main headline', async ({ page }) => {
await expect(page.getByRole('heading', { name: /Everything You Need to Build/i }).first()).toBeVisible(); await expect(
page.getByRole('heading', { name: /Everything You Need to Build/i }).first()
).toBeVisible();
await expect(page.getByText(/Modern Web Applications/i).first()).toBeVisible(); await expect(page.getByText(/Modern Web Applications/i).first()).toBeVisible();
}); });
@@ -274,7 +273,10 @@ test.describe('Homepage - Demo Credentials Modal', () => {
}); });
test.skip('should display regular and admin credentials', async ({ page }) => { test.skip('should display regular and admin credentials', async ({ page }) => {
await page.getByRole('button', { name: /Try Demo/i }).first().click(); await page
.getByRole('button', { name: /Try Demo/i })
.first()
.click();
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await dialog.waitFor({ state: 'visible' }); await dialog.waitFor({ state: 'visible' });
@@ -292,7 +294,10 @@ test.describe('Homepage - Demo Credentials Modal', () => {
// Grant clipboard permissions // Grant clipboard permissions
await context.grantPermissions(['clipboard-read', 'clipboard-write']); await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.getByRole('button', { name: /Try Demo/i }).first().click(); await page
.getByRole('button', { name: /Try Demo/i })
.first()
.click();
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await dialog.waitFor({ state: 'visible' }); await dialog.waitFor({ state: 'visible' });
@@ -306,23 +311,26 @@ test.describe('Homepage - Demo Credentials Modal', () => {
}); });
test.skip('should navigate to login page from modal', async ({ page }) => { test.skip('should navigate to login page from modal', async ({ page }) => {
await page.getByRole('button', { name: /Try Demo/i }).first().click(); await page
.getByRole('button', { name: /Try Demo/i })
.first()
.click();
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await dialog.waitFor({ state: 'visible' }); await dialog.waitFor({ state: 'visible' });
const loginLink = dialog.getByRole('link', { name: /Go to Login/i }); const loginLink = dialog.getByRole('link', { name: /Go to Login/i });
await Promise.all([ await Promise.all([page.waitForURL('/login'), loginLink.click()]);
page.waitForURL('/login'),
loginLink.click()
]);
await expect(page).toHaveURL('/login'); await expect(page).toHaveURL('/login');
}); });
test.skip('should close modal when clicking close button', async ({ page }) => { test.skip('should close modal when clicking close button', async ({ page }) => {
await page.getByRole('button', { name: /Try Demo/i }).first().click(); await page
.getByRole('button', { name: /Try Demo/i })
.first()
.click();
const dialog = page.getByRole('dialog'); const dialog = page.getByRole('dialog');
await dialog.waitFor({ state: 'visible' }); await dialog.waitFor({ state: 'visible' });
@@ -343,7 +351,9 @@ test.describe('Homepage - Animated Terminal', () => {
// Scroll to terminal section // Scroll to terminal section
await page.locator('text=Get Started in Seconds').first().scrollIntoViewIfNeeded(); await page.locator('text=Get Started in Seconds').first().scrollIntoViewIfNeeded();
await expect(page.getByRole('heading', { name: /Get Started in Seconds/i }).first()).toBeVisible(); await expect(
page.getByRole('heading', { name: /Get Started in Seconds/i }).first()
).toBeVisible();
await expect(page.getByText(/Clone, run, and start building/i).first()).toBeVisible(); await expect(page.getByText(/Clone, run, and start building/i).first()).toBeVisible();
}); });
@@ -434,7 +444,9 @@ test.describe('Homepage - Feature Sections', () => {
}); });
test('should display tech stack section', async ({ page }) => { test('should display tech stack section', async ({ page }) => {
await expect(page.getByRole('heading', { name: /Modern, Type-Safe, Production-Grade Stack/i })).toBeVisible(); await expect(
page.getByRole('heading', { name: /Modern, Type-Safe, Production-Grade Stack/i })
).toBeVisible();
// Check for key technologies // Check for key technologies
await expect(page.getByText('FastAPI').first()).toBeVisible(); await expect(page.getByText('FastAPI').first()).toBeVisible();

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
const nextJest = require('next/jest') const nextJest = require('next/jest');
const createJestConfig = nextJest({ const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './', dir: './',
}) });
// Add any custom config to be passed to Jest // Add any custom config to be passed to Jest
const customJestConfig = { const customJestConfig = {
@@ -12,10 +12,7 @@ const customJestConfig = {
moduleNameMapper: { moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1', '^@/(.*)$': '<rootDir>/src/$1',
}, },
testMatch: [ testMatch: ['<rootDir>/tests/**/*.test.ts', '<rootDir>/tests/**/*.test.tsx'],
'<rootDir>/tests/**/*.test.ts',
'<rootDir>/tests/**/*.test.tsx',
],
transformIgnorePatterns: [ transformIgnorePatterns: [
'node_modules/(?!(react-syntax-highlighter|refractor|hastscript|hast-.*|unist-.*|property-information|space-separated-tokens|comma-separated-tokens|web-namespaces)/)', 'node_modules/(?!(react-syntax-highlighter|refractor|hastscript|hast-.*|unist-.*|property-information|space-separated-tokens|comma-separated-tokens|web-namespaces)/)',
], ],
@@ -44,7 +41,7 @@ const customJestConfig = {
statements: 90, statements: 90,
}, },
}, },
} };
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig) module.exports = createJestConfig(customJestConfig);

View File

@@ -1,5 +1,5 @@
// Learn more: https://github.com/testing-library/jest-dom // Learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom' import '@testing-library/jest-dom';
import 'whatwg-fetch'; // Polyfill fetch API import 'whatwg-fetch'; // Polyfill fetch API
import { Crypto } from '@peculiar/webcrypto'; import { Crypto } from '@peculiar/webcrypto';

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,24 @@
import type { NextConfig } from "next"; import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
// Ensure we can connect to the backend in Docker // Ensure we can connect to the backend in Docker
async rewrites() { async rewrites() {
return [ return [
{ {
source: '/api/:path*', source: '/api/:path*',
destination: 'http://backend:8000/:path*', destination: 'http://backend:8000/:path*',
}, },
]; ];
}, },
// ESLint configuration // ESLint configuration
eslint: { eslint: {
ignoreDuringBuilds: false, ignoreDuringBuilds: false,
dirs: ['src'], dirs: ['src'],
}, },
// Production optimizations // Production optimizations
reactStrictMode: true, reactStrictMode: true,
// Note: swcMinify is default in Next.js 15 // Note: swcMinify is default in Next.js 15
}; };
export default nextConfig; export default nextConfig;

View File

@@ -1,5 +1,5 @@
const config = { const config = {
plugins: ["@tailwindcss/postcss"], plugins: ['@tailwindcss/postcss'],
}; };
export default config; export default config;

View File

@@ -94,7 +94,7 @@ async function convertV8ToIstanbul() {
// Read all V8 coverage files // Read all V8 coverage files
const files = await fs.readdir(rawDir); const files = await fs.readdir(rawDir);
const jsonFiles = files.filter(f => f.endsWith('.json')); const jsonFiles = files.filter((f) => f.endsWith('.json'));
if (jsonFiles.length === 0) { if (jsonFiles.length === 0) {
console.log('⚠️ No coverage files found in:', rawDir); console.log('⚠️ No coverage files found in:', rawDir);
@@ -122,10 +122,7 @@ async function convertV8ToIstanbul() {
for (const entry of v8Coverage) { for (const entry of v8Coverage) {
try { try {
// Skip non-source files // Skip non-source files
if ( if (!entry.url.startsWith('http://localhost') && !entry.url.startsWith('file://')) {
!entry.url.startsWith('http://localhost') &&
!entry.url.startsWith('file://')
) {
continue; continue;
} }
@@ -174,7 +171,6 @@ async function convertV8ToIstanbul() {
// Merge into combined coverage // Merge into combined coverage
Object.assign(istanbulCoverage, converted); Object.assign(istanbulCoverage, converted);
totalConverted++; totalConverted++;
} catch (error: any) { } catch (error: any) {
console.log(` ⚠️ Skipped ${entry.url}: ${error.message}`); console.log(` ⚠️ Skipped ${entry.url}: ${error.message}`);
totalSkipped++; totalSkipped++;
@@ -198,7 +194,7 @@ async function convertV8ToIstanbul() {
if (totalConverted === 0) { if (totalConverted === 0) {
console.log('⚠️ No files were converted. Possible reasons:'); console.log('⚠️ No files were converted. Possible reasons:');
console.log(' • V8 coverage doesn\'t contain source files from src/'); console.log(" • V8 coverage doesn't contain source files from src/");
console.log(' • Coverage was collected for build artifacts instead of source'); console.log(' • Coverage was collected for build artifacts instead of source');
console.log(' • Source maps are not correctly configured\n'); console.log(' • Source maps are not correctly configured\n');
console.log('💡 Consider using Istanbul instrumentation instead (see guide)\n'); console.log('💡 Consider using Istanbul instrumentation instead (see guide)\n');

View File

@@ -59,11 +59,9 @@ async function mergeCoverage() {
const jestCoveragePath = path.join(process.cwd(), 'coverage/coverage-final.json'); const jestCoveragePath = path.join(process.cwd(), 'coverage/coverage-final.json');
if (fs.existsSync(jestCoveragePath)) { if (fs.existsSync(jestCoveragePath)) {
const jestCoverage: CoverageData = JSON.parse( const jestCoverage: CoverageData = JSON.parse(fs.readFileSync(jestCoveragePath, 'utf-8'));
fs.readFileSync(jestCoveragePath, 'utf-8')
);
Object.keys(jestCoverage).forEach(file => jestFiles.add(file)); Object.keys(jestCoverage).forEach((file) => jestFiles.add(file));
stats.jestFiles = jestFiles.size; stats.jestFiles = jestFiles.size;
console.log(` ✅ Loaded ${stats.jestFiles} files from Jest coverage`); console.log(` ✅ Loaded ${stats.jestFiles} files from Jest coverage`);
@@ -78,7 +76,7 @@ async function mergeCoverage() {
const e2eDir = path.join(process.cwd(), 'coverage-e2e/.nyc_output'); const e2eDir = path.join(process.cwd(), 'coverage-e2e/.nyc_output');
if (fs.existsSync(e2eDir)) { if (fs.existsSync(e2eDir)) {
const files = fs.readdirSync(e2eDir).filter(f => f.endsWith('.json')); const files = fs.readdirSync(e2eDir).filter((f) => f.endsWith('.json'));
if (files.length === 0) { if (files.length === 0) {
console.log(' ⚠️ No E2E coverage files found in:', e2eDir); console.log(' ⚠️ No E2E coverage files found in:', e2eDir);
@@ -89,7 +87,7 @@ async function mergeCoverage() {
fs.readFileSync(path.join(e2eDir, file), 'utf-8') fs.readFileSync(path.join(e2eDir, file), 'utf-8')
); );
Object.keys(coverage).forEach(f => e2eFiles.add(f)); Object.keys(coverage).forEach((f) => e2eFiles.add(f));
map.merge(coverage); map.merge(coverage);
console.log(` ✅ Loaded E2E coverage from: ${file}`); console.log(` ✅ Loaded E2E coverage from: ${file}`);
} }
@@ -104,7 +102,7 @@ async function mergeCoverage() {
// Step 3: Calculate statistics // Step 3: Calculate statistics
stats.combinedFiles = map.files().length; stats.combinedFiles = map.files().length;
map.files().forEach(file => { map.files().forEach((file) => {
const inJest = jestFiles.has(file); const inJest = jestFiles.has(file);
const inE2E = e2eFiles.has(file); const inE2E = e2eFiles.has(file);
@@ -146,10 +144,18 @@ async function mergeCoverage() {
console.log('\n' + '='.repeat(70)); console.log('\n' + '='.repeat(70));
console.log('📊 COMBINED COVERAGE SUMMARY'); console.log('📊 COMBINED COVERAGE SUMMARY');
console.log('='.repeat(70)); console.log('='.repeat(70));
console.log(`\n Statements: ${summary.statements.pct.toFixed(2)}% (${summary.statements.covered}/${summary.statements.total})`); console.log(
console.log(` Branches: ${summary.branches.pct.toFixed(2)}% (${summary.branches.covered}/${summary.branches.total})`); `\n Statements: ${summary.statements.pct.toFixed(2)}% (${summary.statements.covered}/${summary.statements.total})`
console.log(` Functions: ${summary.functions.pct.toFixed(2)}% (${summary.functions.covered}/${summary.functions.total})`); );
console.log(` Lines: ${summary.lines.pct.toFixed(2)}% (${summary.lines.covered}/${summary.lines.total})`); console.log(
` Branches: ${summary.branches.pct.toFixed(2)}% (${summary.branches.covered}/${summary.branches.total})`
);
console.log(
` Functions: ${summary.functions.pct.toFixed(2)}% (${summary.functions.covered}/${summary.functions.total})`
);
console.log(
` Lines: ${summary.lines.pct.toFixed(2)}% (${summary.lines.covered}/${summary.lines.total})`
);
console.log('\n' + '-'.repeat(70)); console.log('\n' + '-'.repeat(70));
console.log('📁 FILE COVERAGE BREAKDOWN'); console.log('📁 FILE COVERAGE BREAKDOWN');
@@ -162,10 +168,12 @@ async function mergeCoverage() {
// Show E2E-only files (these were excluded from Jest) // Show E2E-only files (these were excluded from Jest)
if (stats.e2eOnlyFiles.length > 0) { if (stats.e2eOnlyFiles.length > 0) {
console.log('\n 📋 Files covered ONLY by E2E tests (excluded from unit tests):'); console.log('\n 📋 Files covered ONLY by E2E tests (excluded from unit tests):');
stats.e2eOnlyFiles.slice(0, 10).forEach(file => { stats.e2eOnlyFiles.slice(0, 10).forEach((file) => {
const fileCoverage = map.fileCoverageFor(file); const fileCoverage = map.fileCoverageFor(file);
const fileSummary = fileCoverage.toSummary(); const fileSummary = fileCoverage.toSummary();
console.log(`${path.relative(process.cwd(), file)} (${fileSummary.statements.pct.toFixed(1)}%)`); console.log(
`${path.relative(process.cwd(), file)} (${fileSummary.statements.pct.toFixed(1)}%)`
);
}); });
if (stats.e2eOnlyFiles.length > 10) { if (stats.e2eOnlyFiles.length > 10) {
console.log(` ... and ${stats.e2eOnlyFiles.length - 10} more`); console.log(` ... and ${stats.e2eOnlyFiles.length - 10} more`);
@@ -190,7 +198,9 @@ async function mergeCoverage() {
const actual = (summary as any)[metric].pct; const actual = (summary as any)[metric].pct;
const passed = actual >= threshold; const passed = actual >= threshold;
const icon = passed ? '✅' : '❌'; const icon = passed ? '✅' : '❌';
console.log(` ${icon} ${metric.padEnd(12)}: ${actual.toFixed(2)}% (threshold: ${threshold}%)`); console.log(
` ${icon} ${metric.padEnd(12)}: ${actual.toFixed(2)}% (threshold: ${threshold}%)`
);
if (!passed) thresholdsFailed = true; if (!passed) thresholdsFailed = true;
}); });

View File

@@ -5,10 +5,6 @@ export const metadata: Metadata = {
title: 'Authentication', title: 'Authentication',
}; };
export default function AuthLayout({ export default function AuthLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
return <AuthLayoutClient>{children}</AuthLayoutClient>; return <AuthLayoutClient>{children}</AuthLayoutClient>;
} }

View File

@@ -19,18 +19,13 @@ export default function LoginPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center"> <div className="text-center">
<h2 className="text-3xl font-bold tracking-tight"> <h2 className="text-3xl font-bold tracking-tight">Sign in to your account</h2>
Sign in to your account
</h2>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Access your dashboard and manage your account Access your dashboard and manage your account
</p> </p>
</div> </div>
<LoginForm <LoginForm showRegisterLink showPasswordResetLink />
showRegisterLink
showPasswordResetLink
/>
</div> </div>
); );
} }

View File

@@ -14,7 +14,10 @@ import Link from 'next/link';
// Code-split PasswordResetConfirmForm (319 lines) // Code-split PasswordResetConfirmForm (319 lines)
const PasswordResetConfirmForm = dynamic( const PasswordResetConfirmForm = dynamic(
/* istanbul ignore next - Next.js dynamic import, tested via component */ /* istanbul ignore next - Next.js dynamic import, tested via component */
() => import('@/components/auth/PasswordResetConfirmForm').then((mod) => ({ default: mod.PasswordResetConfirmForm })), () =>
import('@/components/auth/PasswordResetConfirmForm').then((mod) => ({
default: mod.PasswordResetConfirmForm,
})),
{ {
loading: () => ( loading: () => (
<div className="space-y-4"> <div className="space-y-4">
@@ -53,15 +56,12 @@ export default function PasswordResetConfirmContent() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center"> <div className="text-center">
<h2 className="text-3xl font-bold tracking-tight"> <h2 className="text-3xl font-bold tracking-tight">Invalid Reset Link</h2>
Invalid Reset Link
</h2>
</div> </div>
<Alert variant="destructive"> <Alert variant="destructive">
<p className="text-sm"> <p className="text-sm">
This password reset link is invalid or has expired. Please request a new This password reset link is invalid or has expired. Please request a new password reset.
password reset.
</p> </p>
</Alert> </Alert>

View File

@@ -8,16 +8,16 @@ import PasswordResetConfirmContent from './PasswordResetConfirmContent';
export default function PasswordResetConfirmPage() { export default function PasswordResetConfirmPage() {
return ( return (
<Suspense fallback={ <Suspense
<div className="space-y-6"> fallback={
<div className="text-center"> <div className="space-y-6">
<h2 className="text-3xl font-bold tracking-tight">Set new password</h2> <div className="text-center">
<p className="mt-2 text-sm text-muted-foreground"> <h2 className="text-3xl font-bold tracking-tight">Set new password</h2>
Loading... <p className="mt-2 text-sm text-muted-foreground">Loading...</p>
</p> </div>
</div> </div>
</div> }
}> >
<PasswordResetConfirmContent /> <PasswordResetConfirmContent />
</Suspense> </Suspense>
); );

View File

@@ -8,9 +8,10 @@ import dynamic from 'next/dynamic';
// Code-split PasswordResetRequestForm // Code-split PasswordResetRequestForm
const PasswordResetRequestForm = dynamic( const PasswordResetRequestForm = dynamic(
/* istanbul ignore next - Next.js dynamic import, tested via component */ /* istanbul ignore next - Next.js dynamic import, tested via component */
() => import('@/components/auth/PasswordResetRequestForm').then((mod) => ({ () =>
default: mod.PasswordResetRequestForm import('@/components/auth/PasswordResetRequestForm').then((mod) => ({
})), default: mod.PasswordResetRequestForm,
})),
{ {
loading: () => ( loading: () => (
<div className="space-y-4"> <div className="space-y-4">
@@ -25,9 +26,7 @@ export default function PasswordResetPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center"> <div className="text-center">
<h2 className="text-3xl font-bold tracking-tight"> <h2 className="text-3xl font-bold tracking-tight">Reset your password</h2>
Reset your password
</h2>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
We&apos;ll send you an email with instructions to reset your password We&apos;ll send you an email with instructions to reset your password
</p> </p>

View File

@@ -19,9 +19,7 @@ export default function RegisterPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="text-center"> <div className="text-center">
<h2 className="text-3xl font-bold tracking-tight"> <h2 className="text-3xl font-bold tracking-tight">Create your account</h2>
Create your account
</h2>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Get started with your free account today Get started with your free account today
</p> </p>

View File

@@ -15,18 +15,12 @@ export const metadata: Metadata = {
}, },
}; };
export default function AuthenticatedLayout({ export default function AuthenticatedLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
return ( return (
<AuthGuard> <AuthGuard>
<div className="flex min-h-screen flex-col"> <div className="flex min-h-screen flex-col">
<Header /> <Header />
<main className="flex-1"> <main className="flex-1">{children}</main>
{children}
</main>
<Footer /> <Footer />
</div> </div>
</AuthGuard> </AuthGuard>

View File

@@ -40,11 +40,7 @@ const settingsTabs = [
}, },
]; ];
export default function SettingsLayout({ export default function SettingsLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname(); const pathname = usePathname();
// Determine active tab based on pathname // Determine active tab based on pathname
@@ -54,12 +50,8 @@ export default function SettingsLayout({
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
{/* Page Header */} {/* Page Header */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold text-foreground"> <h1 className="text-3xl font-bold text-foreground">Settings</h1>
Settings <p className="mt-2 text-muted-foreground">Manage your account settings and preferences</p>
</h1>
<p className="mt-2 text-muted-foreground">
Manage your account settings and preferences
</p>
</div> </div>
{/* Tabs Navigation */} {/* Tabs Navigation */}
@@ -79,9 +71,7 @@ export default function SettingsLayout({
</TabsList> </TabsList>
{/* Tab Content */} {/* Tab Content */}
<div className="rounded-lg border bg-card text-card-foreground p-6"> <div className="rounded-lg border bg-card text-card-foreground p-6">{children}</div>
{children}
</div>
</Tabs> </Tabs>
</div> </div>
); );

View File

@@ -11,9 +11,7 @@ export default function PasswordSettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-2xl font-semibold text-foreground"> <h2 className="text-2xl font-semibold text-foreground">Password Settings</h2>
Password Settings
</h2>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">
Change your password to keep your account secure Change your password to keep your account secure
</p> </p>

View File

@@ -4,7 +4,7 @@
*/ */
/* istanbul ignore next - Next.js type import for metadata */ /* istanbul ignore next - Next.js type import for metadata */
import type { Metadata} from 'next'; import type { Metadata } from 'next';
/* istanbul ignore next - Next.js metadata, not executable code */ /* istanbul ignore next - Next.js metadata, not executable code */
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -14,12 +14,8 @@ export const metadata: Metadata = {
export default function PreferencesPage() { export default function PreferencesPage() {
return ( return (
<div> <div>
<h2 className="text-2xl font-semibold text-foreground mb-4"> <h2 className="text-2xl font-semibold text-foreground mb-4">Preferences</h2>
Preferences <p className="text-muted-foreground">Configure your preferences (Coming in Task 3.5)</p>
</h2>
<p className="text-muted-foreground">
Configure your preferences (Coming in Task 3.5)
</p>
</div> </div>
); );
} }

View File

@@ -11,12 +11,8 @@ export default function ProfileSettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-2xl font-semibold text-foreground"> <h2 className="text-2xl font-semibold text-foreground">Profile Settings</h2>
Profile Settings <p className="text-muted-foreground mt-1">Manage your profile information</p>
</h2>
<p className="text-muted-foreground mt-1">
Manage your profile information
</p>
</div> </div>
<ProfileSettingsForm /> <ProfileSettingsForm />

View File

@@ -11,9 +11,7 @@ export default function SessionsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-2xl font-semibold text-foreground"> <h2 className="text-2xl font-semibold text-foreground">Active Sessions</h2>
Active Sessions
</h2>
<p className="text-muted-foreground mt-1"> <p className="text-muted-foreground mt-1">
View and manage devices signed in to your account View and manage devices signed in to your account
</p> </p>

View File

@@ -17,11 +17,7 @@ export const metadata: Metadata = {
}, },
}; };
export default function AdminLayout({ export default function AdminLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
return ( return (
<AuthGuard requireAdmin> <AuthGuard requireAdmin>
<a <a

View File

@@ -27,9 +27,7 @@ export default function AdminPage() {
<div className="space-y-8"> <div className="space-y-8">
{/* Page Header */} {/* Page Header */}
<div> <div>
<h1 className="text-3xl font-bold tracking-tight"> <h1 className="text-3xl font-bold tracking-tight">Admin Dashboard</h1>
Admin Dashboard
</h1>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
Manage users, organizations, and system settings Manage users, organizations, and system settings
</p> </p>
@@ -72,9 +70,7 @@ export default function AdminPage() {
<Settings className="h-5 w-5 text-primary" aria-hidden="true" /> <Settings className="h-5 w-5 text-primary" aria-hidden="true" />
<h3 className="font-semibold">System Settings</h3> <h3 className="font-semibold">System Settings</h3>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Configure system-wide settings</p>
Configure system-wide settings
</p>
</div> </div>
</Link> </Link>
</div> </div>

View File

@@ -27,9 +27,7 @@ export default function AdminSettingsPage() {
</Button> </Button>
</Link> </Link>
<div> <div>
<h1 className="text-3xl font-bold tracking-tight"> <h1 className="text-3xl font-bold tracking-tight">System Settings</h1>
System Settings
</h1>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
Configure system-wide settings and preferences Configure system-wide settings and preferences
</p> </p>
@@ -38,16 +36,12 @@ export default function AdminSettingsPage() {
{/* Placeholder Content */} {/* Placeholder Content */}
<div className="rounded-lg border bg-card p-12 text-center"> <div className="rounded-lg border bg-card p-12 text-center">
<h3 className="text-xl font-semibold mb-2"> <h3 className="text-xl font-semibold mb-2">System Settings Coming Soon</h3>
System Settings Coming Soon
</h3>
<p className="text-muted-foreground max-w-md mx-auto"> <p className="text-muted-foreground max-w-md mx-auto">
This page will allow you to configure system-wide settings, This page will allow you to configure system-wide settings, preferences, and advanced
preferences, and advanced options. options.
</p>
<p className="text-sm text-muted-foreground mt-4">
Features will include:
</p> </p>
<p className="text-sm text-muted-foreground mt-4">Features will include:</p>
<ul className="text-sm text-muted-foreground mt-2 max-w-sm mx-auto text-left"> <ul className="text-sm text-muted-foreground mt-2 max-w-sm mx-auto text-left">
<li> General system configuration</li> <li> General system configuration</li>
<li> Email and notification settings</li> <li> Email and notification settings</li>

View File

@@ -28,12 +28,8 @@ export default function AdminUsersPage() {
</Button> </Button>
</Link> </Link>
<div> <div>
<h1 className="text-3xl font-bold tracking-tight"> <h1 className="text-3xl font-bold tracking-tight">User Management</h1>
User Management <p className="mt-2 text-muted-foreground">View, create, and manage user accounts</p>
</h1>
<p className="mt-2 text-muted-foreground">
View, create, and manage user accounts
</p>
</div> </div>
</div> </div>

View File

@@ -11,7 +11,9 @@ import dynamic from 'next/dynamic';
const ComponentShowcase = dynamic( const ComponentShowcase = dynamic(
() => import('@/components/dev/ComponentShowcase').then((mod) => mod.ComponentShowcase), () => import('@/components/dev/ComponentShowcase').then((mod) => mod.ComponentShowcase),
{ {
loading: () => <div className="p-8 text-center text-muted-foreground">Loading components...</div>, loading: () => (
<div className="p-8 text-center text-muted-foreground">Loading components...</div>
),
} }
); );

View File

@@ -21,9 +21,9 @@ export async function generateStaticParams() {
try { try {
const files = await fs.readdir(docsDir); const files = await fs.readdir(docsDir);
const mdFiles = files.filter(file => file.endsWith('.md')); const mdFiles = files.filter((file) => file.endsWith('.md'));
return mdFiles.map(file => ({ return mdFiles.map((file) => ({
slug: [file.replace(/\.md$/, '')], slug: [file.replace(/\.md$/, '')],
})); }));
} catch { } catch {
@@ -63,12 +63,7 @@ export default async function DocPage({ params }: DocPageProps) {
return ( return (
<div className="bg-background"> <div className="bg-background">
{/* Breadcrumbs */} {/* Breadcrumbs */}
<DevBreadcrumbs <DevBreadcrumbs items={[{ label: 'Documentation', href: '/dev/docs' }, { label: title }]} />
items={[
{ label: 'Documentation', href: '/dev/docs' },
{ label: title }
]}
/>
<div className="container mx-auto px-4 py-12"> <div className="container mx-auto px-4 py-12">
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">

View File

@@ -5,7 +5,17 @@
*/ */
import Link from 'next/link'; import Link from 'next/link';
import { BookOpen, Sparkles, Layout, Palette, Code2, FileCode, Accessibility, Lightbulb, Search } from 'lucide-react'; import {
BookOpen,
Sparkles,
Layout,
Palette,
Code2,
FileCode,
Accessibility,
Lightbulb,
Search,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -106,9 +116,7 @@ export default function DocsHub() {
<section className="border-b bg-gradient-to-b from-background to-muted/20 py-12"> <section className="border-b bg-gradient-to-b from-background to-muted/20 py-12">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="mx-auto max-w-3xl text-center"> <div className="mx-auto max-w-3xl text-center">
<h2 className="text-4xl font-bold tracking-tight mb-4"> <h2 className="text-4xl font-bold tracking-tight mb-4">Design System Documentation</h2>
Design System Documentation
</h2>
<p className="text-lg text-muted-foreground mb-8"> <p className="text-lg text-muted-foreground mb-8">
Comprehensive guides, best practices, and references for building consistent, Comprehensive guides, best practices, and references for building consistent,
accessible, and maintainable user interfaces with the FastNext design system. accessible, and maintainable user interfaces with the FastNext design system.
@@ -170,9 +178,7 @@ export default function DocsHub() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<CardDescription className="text-base"> <CardDescription className="text-base">{doc.description}</CardDescription>
{doc.description}
</CardDescription>
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
@@ -203,9 +209,7 @@ export default function DocsHub() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<CardDescription> <CardDescription>{doc.description}</CardDescription>
{doc.description}
</CardDescription>
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
@@ -243,9 +247,7 @@ export default function DocsHub() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<CardDescription className="text-base"> <CardDescription className="text-base">{doc.description}</CardDescription>
{doc.description}
</CardDescription>
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
@@ -254,7 +256,6 @@ export default function DocsHub() {
</section> </section>
</div> </div>
</main> </main>
</div> </div>
); );
} }

View File

@@ -14,12 +14,7 @@ import { z } from 'zod';
import { CheckCircle2, Loader2 } from 'lucide-react'; import { CheckCircle2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DevBreadcrumbs } from '@/components/dev/DevBreadcrumbs'; import { DevBreadcrumbs } from '@/components/dev/DevBreadcrumbs';
import { import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
@@ -109,9 +104,8 @@ export default function FormsPage() {
{/* Introduction */} {/* Introduction */}
<div className="max-w-3xl space-y-4"> <div className="max-w-3xl space-y-4">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Complete form implementations using react-hook-form for state management Complete form implementations using react-hook-form for state management and Zod for
and Zod for validation. Includes error handling, loading states, and validation. Includes error handling, loading states, and accessibility features.
accessibility features.
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Badge variant="outline">react-hook-form</Badge> <Badge variant="outline">react-hook-form</Badge>
@@ -170,16 +164,10 @@ const { register, handleSubmit, formState: { errors } } = useForm({
placeholder="you@example.com" placeholder="you@example.com"
{...registerLogin('email')} {...registerLogin('email')}
aria-invalid={!!errorsLogin.email} aria-invalid={!!errorsLogin.email}
aria-describedby={ aria-describedby={errorsLogin.email ? 'login-email-error' : undefined}
errorsLogin.email ? 'login-email-error' : undefined
}
/> />
{errorsLogin.email && ( {errorsLogin.email && (
<p <p id="login-email-error" className="text-sm text-destructive" role="alert">
id="login-email-error"
className="text-sm text-destructive"
role="alert"
>
{errorsLogin.email.message} {errorsLogin.email.message}
</p> </p>
)} )}
@@ -194,9 +182,7 @@ const { register, handleSubmit, formState: { errors } } = useForm({
placeholder="••••••••" placeholder="••••••••"
{...registerLogin('password')} {...registerLogin('password')}
aria-invalid={!!errorsLogin.password} aria-invalid={!!errorsLogin.password}
aria-describedby={ aria-describedby={errorsLogin.password ? 'login-password-error' : undefined}
errorsLogin.password ? 'login-password-error' : undefined
}
/> />
{errorsLogin.password && ( {errorsLogin.password && (
<p <p
@@ -277,10 +263,7 @@ const { register, handleSubmit, formState: { errors } } = useForm({
</form>`} </form>`}
> >
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<form <form onSubmit={handleSubmitContact(onSubmitContact)} className="space-y-4">
onSubmit={handleSubmitContact(onSubmitContact)}
className="space-y-4"
>
{/* Name */} {/* Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="contact-name">Name</Label> <Label htmlFor="contact-name">Name</Label>
@@ -317,9 +300,7 @@ const { register, handleSubmit, formState: { errors } } = useForm({
{/* Category */} {/* Category */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="contact-category">Category</Label> <Label htmlFor="contact-category">Category</Label>
<Select <Select onValueChange={(value) => setValueContact('category', value)}>
onValueChange={(value) => setValueContact('category', value)}
>
<SelectTrigger id="contact-category"> <SelectTrigger id="contact-category">
<SelectValue placeholder="Select a category" /> <SelectValue placeholder="Select a category" />
</SelectTrigger> </SelectTrigger>
@@ -364,9 +345,7 @@ const { register, handleSubmit, formState: { errors } } = useForm({
<Alert className="border-green-500 text-green-600 dark:border-green-400 dark:text-green-400"> <Alert className="border-green-500 text-green-600 dark:border-green-400 dark:text-green-400">
<CheckCircle2 className="h-4 w-4" /> <CheckCircle2 className="h-4 w-4" />
<AlertTitle>Success!</AlertTitle> <AlertTitle>Success!</AlertTitle>
<AlertDescription> <AlertDescription>Your message has been sent successfully.</AlertDescription>
Your message has been sent successfully.
</AlertDescription>
</Alert> </Alert>
)} )}
</form> </form>
@@ -384,7 +363,7 @@ const { register, handleSubmit, formState: { errors } } = useForm({
title="Error State Best Practices" title="Error State Best Practices"
description="Use aria-invalid and aria-describedby for accessibility" description="Use aria-invalid and aria-describedby for accessibility"
before={{ before={{
caption: "No ARIA attributes, poor accessibility", caption: 'No ARIA attributes, poor accessibility',
content: ( content: (
<div className="space-y-2 rounded-lg border p-4"> <div className="space-y-2 rounded-lg border p-4">
<div className="text-sm font-medium">Email</div> <div className="text-sm font-medium">Email</div>
@@ -394,7 +373,7 @@ const { register, handleSubmit, formState: { errors } } = useForm({
), ),
}} }}
after={{ after={{
caption: "With ARIA, screen reader accessible", caption: 'With ARIA, screen reader accessible',
content: ( content: (
<div className="space-y-2 rounded-lg border p-4"> <div className="space-y-2 rounded-lg border p-4">
<div className="text-sm font-medium">Email</div> <div className="text-sm font-medium">Email</div>
@@ -422,15 +401,21 @@ const { register, handleSubmit, formState: { errors } } = useForm({
<ul className="space-y-2 text-sm"> <ul className="space-y-2 text-sm">
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5" /> <CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5" />
<span>Add <code className="text-xs">aria-invalid=true</code> to invalid inputs</span> <span>
Add <code className="text-xs">aria-invalid=true</code> to invalid inputs
</span>
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5" /> <CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5" />
<span>Link errors with <code className="text-xs">aria-describedby</code></span> <span>
Link errors with <code className="text-xs">aria-describedby</code>
</span>
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5" /> <CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5" />
<span>Add <code className="text-xs">role=&quot;alert&quot;</code> to error messages</span> <span>
Add <code className="text-xs">role=&quot;alert&quot;</code> to error messages
</span>
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5" /> <CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5" />
@@ -536,9 +521,7 @@ const { register, handleSubmit, formState: { errors } } = useForm({
<div className="space-y-2"> <div className="space-y-2">
<div className="font-medium text-sm">Optional Field</div> <div className="font-medium text-sm">Optional Field</div>
<code className="block rounded bg-muted p-2 text-xs"> <code className="block rounded bg-muted p-2 text-xs">z.string().optional()</code>
z.string().optional()
</code>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -9,13 +9,7 @@ import Link from 'next/link';
import { Grid3x3 } from 'lucide-react'; import { Grid3x3 } from 'lucide-react';
import { DevBreadcrumbs } from '@/components/dev/DevBreadcrumbs'; import { DevBreadcrumbs } from '@/components/dev/DevBreadcrumbs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Example, ExampleSection } from '@/components/dev/Example'; import { Example, ExampleSection } from '@/components/dev/Example';
import { BeforeAfter } from '@/components/dev/BeforeAfter'; import { BeforeAfter } from '@/components/dev/BeforeAfter';
@@ -37,9 +31,8 @@ export default function LayoutsPage() {
{/* Introduction */} {/* Introduction */}
<div className="max-w-3xl space-y-4"> <div className="max-w-3xl space-y-4">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
These 5 essential layout patterns cover 80% of interface needs. Each These 5 essential layout patterns cover 80% of interface needs. Each pattern includes
pattern includes live examples, before/after comparisons, and copy-paste live examples, before/after comparisons, and copy-paste code.
code.
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Badge variant="outline">Grid vs Flex</Badge> <Badge variant="outline">Grid vs Flex</Badge>
@@ -79,14 +72,12 @@ export default function LayoutsPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Content Card</CardTitle> <CardTitle>Content Card</CardTitle>
<CardDescription> <CardDescription>Constrained to max-w-4xl for readability</CardDescription>
Constrained to max-w-4xl for readability
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Your main content goes here. The max-w-4xl constraint Your main content goes here. The max-w-4xl constraint ensures comfortable
ensures comfortable reading width. reading width.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -99,25 +90,24 @@ export default function LayoutsPage() {
title="Common Mistake: No Width Constraint" title="Common Mistake: No Width Constraint"
description="Content should not span full viewport width" description="Content should not span full viewport width"
before={{ before={{
caption: "No max-width, hard to read on wide screens", caption: 'No max-width, hard to read on wide screens',
content: ( content: (
<div className="w-full space-y-4 bg-background p-4 rounded"> <div className="w-full space-y-4 bg-background p-4 rounded">
<h3 className="font-semibold">Full Width Content</h3> <h3 className="font-semibold">Full Width Content</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This text spans the entire width, making it hard to read on This text spans the entire width, making it hard to read on large screens.
large screens. Lines become too long. Lines become too long.
</p> </p>
</div> </div>
), ),
}} }}
after={{ after={{
caption: "Constrained with max-w for better readability", caption: 'Constrained with max-w for better readability',
content: ( content: (
<div className="max-w-2xl mx-auto space-y-4 bg-background p-4 rounded"> <div className="max-w-2xl mx-auto space-y-4 bg-background p-4 rounded">
<h3 className="font-semibold">Constrained Content</h3> <h3 className="font-semibold">Constrained Content</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This text has a max-width, creating comfortable line lengths This text has a max-width, creating comfortable line lengths for reading.
for reading.
</p> </p>
</div> </div>
), ),
@@ -149,14 +139,10 @@ export default function LayoutsPage() {
{[1, 2, 3, 4, 5, 6].map((i) => ( {[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i}> <Card key={i}>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium">Metric {i}</CardTitle>
Metric {i}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">{(Math.random() * 1000).toFixed(0)}</div>
{(Math.random() * 1000).toFixed(0)}
</div>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
+{(Math.random() * 20).toFixed(1)}% from last month +{(Math.random() * 20).toFixed(1)}% from last month
</p> </p>
@@ -170,7 +156,7 @@ export default function LayoutsPage() {
title="Grid vs Flex for Equal Columns" title="Grid vs Flex for Equal Columns"
description="Use Grid for equal-width columns, not Flex" description="Use Grid for equal-width columns, not Flex"
before={{ before={{
caption: "flex with flex-1 - uneven wrapping", caption: 'flex with flex-1 - uneven wrapping',
content: ( content: (
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px] rounded border bg-background p-4"> <div className="flex-1 min-w-[200px] rounded border bg-background p-4">
@@ -186,7 +172,7 @@ export default function LayoutsPage() {
), ),
}} }}
after={{ after={{
caption: "grid with grid-cols - consistent sizing", caption: 'grid with grid-cols - consistent sizing',
content: ( content: (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="rounded border bg-background p-4"> <div className="rounded border bg-background p-4">
@@ -231,9 +217,7 @@ export default function LayoutsPage() {
<Card className="max-w-md mx-auto"> <Card className="max-w-md mx-auto">
<CardHeader> <CardHeader>
<CardTitle>Login</CardTitle> <CardTitle>Login</CardTitle>
<CardDescription> <CardDescription>Enter your credentials to continue</CardDescription>
Enter your credentials to continue
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form className="space-y-4"> <form className="space-y-4">
@@ -288,10 +272,7 @@ export default function LayoutsPage() {
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{['Dashboard', 'Settings', 'Profile'].map((item) => ( {['Dashboard', 'Settings', 'Profile'].map((item) => (
<div <div key={item} className="rounded-md bg-muted px-3 py-2 text-sm">
key={item}
className="rounded-md bg-muted px-3 py-2 text-sm"
>
{item} {item}
</div> </div>
))} ))}
@@ -304,14 +285,12 @@ export default function LayoutsPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Main Content</CardTitle> <CardTitle>Main Content</CardTitle>
<CardDescription> <CardDescription>Fixed 240px sidebar, fluid main area</CardDescription>
Fixed 240px sidebar, fluid main area
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
The sidebar remains 240px wide while the main content area The sidebar remains 240px wide while the main content area flexes to fill
flexes to fill remaining space. remaining space.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -344,9 +323,7 @@ export default function LayoutsPage() {
<Card className="max-w-sm w-full"> <Card className="max-w-sm w-full">
<CardHeader> <CardHeader>
<CardTitle>Centered Card</CardTitle> <CardTitle>Centered Card</CardTitle>
<CardDescription> <CardDescription>Centered vertically and horizontally</CardDescription>
Centered vertically and horizontally
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -422,9 +399,7 @@ export default function LayoutsPage() {
<CardDescription>Most common pattern</CardDescription> <CardDescription>Most common pattern</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<code className="text-xs"> <code className="text-xs">grid-cols-1 md:grid-cols-2 lg:grid-cols-3</code>
grid-cols-1 md:grid-cols-2 lg:grid-cols-3
</code>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Mobile: 1 column Mobile: 1 column
<br /> <br />
@@ -441,9 +416,7 @@ export default function LayoutsPage() {
<CardDescription>For smaller cards</CardDescription> <CardDescription>For smaller cards</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<code className="text-xs"> <code className="text-xs">grid-cols-1 md:grid-cols-2 lg:grid-cols-4</code>
grid-cols-1 md:grid-cols-2 lg:grid-cols-4
</code>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Mobile: 1 column Mobile: 1 column
<br /> <br />
@@ -475,9 +448,7 @@ export default function LayoutsPage() {
<CardDescription>Mobile navigation</CardDescription> <CardDescription>Mobile navigation</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<code className="text-xs"> <code className="text-xs">hidden lg:block</code>
hidden lg:block
</code>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Mobile: Hidden (use menu) Mobile: Hidden (use menu)
<br /> <br />

View File

@@ -6,23 +6,9 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { import { Palette, Layout, Ruler, FileText, BookOpen, ArrowRight, Sparkles } from 'lucide-react';
Palette,
Layout,
Ruler,
FileText,
BookOpen,
ArrowRight,
Sparkles,
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@@ -98,13 +84,11 @@ export default function DesignSystemHub() {
<div className="space-y-4 max-w-3xl"> <div className="space-y-4 max-w-3xl">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sparkles className="h-8 w-8 text-primary" /> <Sparkles className="h-8 w-8 text-primary" />
<h1 className="text-4xl font-bold tracking-tight"> <h1 className="text-4xl font-bold tracking-tight">Design System Hub</h1>
Design System Hub
</h1>
</div> </div>
<p className="text-lg text-muted-foreground"> <p className="text-lg text-muted-foreground">
Interactive demonstrations, live examples, and comprehensive documentation for Interactive demonstrations, live examples, and comprehensive documentation for the
the FastNext design system. Built with shadcn/ui + Tailwind CSS 4. FastNext design system. Built with shadcn/ui + Tailwind CSS 4.
</p> </p>
</div> </div>
</div> </div>
@@ -116,9 +100,7 @@ export default function DesignSystemHub() {
{/* Demo Pages Grid */} {/* Demo Pages Grid */}
<section className="space-y-6"> <section className="space-y-6">
<div> <div>
<h2 className="text-2xl font-semibold tracking-tight"> <h2 className="text-2xl font-semibold tracking-tight">Interactive Demonstrations</h2>
Interactive Demonstrations
</h2>
<p className="text-sm text-muted-foreground mt-2"> <p className="text-sm text-muted-foreground mt-2">
Explore live examples with copy-paste code snippets Explore live examples with copy-paste code snippets
</p> </p>
@@ -143,9 +125,7 @@ export default function DesignSystemHub() {
New New
</Badge> </Badge>
)} )}
{page.status === 'enhanced' && ( {page.status === 'enhanced' && <Badge variant="secondary">Enhanced</Badge>}
<Badge variant="secondary">Enhanced</Badge>
)}
</div> </div>
<CardTitle className="mt-4">{page.title}</CardTitle> <CardTitle className="mt-4">{page.title}</CardTitle>
<CardDescription>{page.description}</CardDescription> <CardDescription>{page.description}</CardDescription>
@@ -190,19 +170,13 @@ export default function DesignSystemHub() {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{documentationLinks.map((link) => ( {documentationLinks.map((link) => (
<Link <Link key={link.href} href={link.href} className="group">
key={link.href}
href={link.href}
className="group"
>
<Card className="h-full transition-all hover:border-primary/50 hover:bg-accent/50"> <Card className="h-full transition-all hover:border-primary/50 hover:bg-accent/50">
<CardHeader className="space-y-1"> <CardHeader className="space-y-1">
<CardTitle className="text-base group-hover:text-primary transition-colors"> <CardTitle className="text-base group-hover:text-primary transition-colors">
{link.title} {link.title}
</CardTitle> </CardTitle>
<CardDescription className="text-xs"> <CardDescription className="text-xs">{link.description}</CardDescription>
{link.description}
</CardDescription>
</CardHeader> </CardHeader>
</Card> </Card>
</Link> </Link>
@@ -214,9 +188,7 @@ export default function DesignSystemHub() {
{/* Key Features */} {/* Key Features */}
<section className="space-y-6"> <section className="space-y-6">
<h2 className="text-2xl font-semibold tracking-tight"> <h2 className="text-2xl font-semibold tracking-tight">Key Features</h2>
Key Features
</h2>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Card> <Card>

View File

@@ -10,13 +10,7 @@ import Link from 'next/link';
import { Ruler } from 'lucide-react'; import { Ruler } from 'lucide-react';
import { DevBreadcrumbs } from '@/components/dev/DevBreadcrumbs'; import { DevBreadcrumbs } from '@/components/dev/DevBreadcrumbs';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
// Code-split heavy dev components // Code-split heavy dev components
@@ -52,9 +46,9 @@ export default function SpacingPage() {
{/* Introduction */} {/* Introduction */}
<div className="max-w-3xl space-y-4"> <div className="max-w-3xl space-y-4">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
The Golden Rule: <strong>Parents control spacing, not children.</strong>{' '} The Golden Rule: <strong>Parents control spacing, not children.</strong> Use gap,
Use gap, space-y, and space-x utilities on the parent container. Avoid space-y, and space-x utilities on the parent container. Avoid margins on children
margins on children except for exceptions. except for exceptions.
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Badge variant="outline">gap</Badge> <Badge variant="outline">gap</Badge>
@@ -73,9 +67,7 @@ export default function SpacingPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Common Spacing Values</CardTitle> <CardTitle>Common Spacing Values</CardTitle>
<CardDescription> <CardDescription>Use consistent spacing values from the scale</CardDescription>
Use consistent spacing values from the scale
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
@@ -95,10 +87,7 @@ export default function SpacingPage() {
<span className="text-sm text-muted-foreground">{item.rem}</span> <span className="text-sm text-muted-foreground">{item.rem}</span>
<span className="text-sm">{item.use}</span> <span className="text-sm">{item.use}</span>
<div className="col-span-4"> <div className="col-span-4">
<div <div className="h-2 rounded bg-primary" style={{ width: item.px }}></div>
className="h-2 rounded bg-primary"
style={{ width: item.px }}
></div>
</div> </div>
</div> </div>
))} ))}
@@ -158,10 +147,7 @@ export default function SpacingPage() {
<p className="text-sm font-medium mb-2">Grid (gap-6)</p> <p className="text-sm font-medium mb-2">Grid (gap-6)</p>
<div className="grid grid-cols-3 gap-6"> <div className="grid grid-cols-3 gap-6">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div <div key={i} className="rounded-lg border bg-muted p-3 text-center text-sm">
key={i}
className="rounded-lg border bg-muted p-3 text-center text-sm"
>
Card {i} Card {i}
</div> </div>
))} ))}
@@ -207,12 +193,8 @@ export default function SpacingPage() {
<div className="rounded-lg border bg-muted p-3 text-sm"> <div className="rounded-lg border bg-muted p-3 text-sm">
First item (no margin) First item (no margin)
</div> </div>
<div className="rounded-lg border bg-muted p-3 text-sm"> <div className="rounded-lg border bg-muted p-3 text-sm">Second item (mt-4)</div>
Second item (mt-4) <div className="rounded-lg border bg-muted p-3 text-sm">Third item (mt-4)</div>
</div>
<div className="rounded-lg border bg-muted p-3 text-sm">
Third item (mt-4)
</div>
</div> </div>
</div> </div>
@@ -245,7 +227,7 @@ export default function SpacingPage() {
title="Don't Let Children Control Spacing" title="Don't Let Children Control Spacing"
description="Parent should control spacing, not children" description="Parent should control spacing, not children"
before={{ before={{
caption: "Children control their own spacing with mt-4", caption: 'Children control their own spacing with mt-4',
content: ( content: (
<div className="space-y-2 rounded-lg border p-4"> <div className="space-y-2 rounded-lg border p-4">
<div className="rounded bg-muted p-2 text-xs"> <div className="rounded bg-muted p-2 text-xs">
@@ -264,14 +246,12 @@ export default function SpacingPage() {
), ),
}} }}
after={{ after={{
caption: "Parent controls spacing with space-y-4", caption: 'Parent controls spacing with space-y-4',
content: ( content: (
<div className="space-y-4 rounded-lg border p-4"> <div className="space-y-4 rounded-lg border p-4">
<div className="rounded bg-muted p-2 text-xs"> <div className="rounded bg-muted p-2 text-xs">
<div>Child 1</div> <div>Child 1</div>
<code className="text-[10px] text-green-600"> <code className="text-[10px] text-green-600">parent uses space-y-4</code>
parent uses space-y-4
</code>
</div> </div>
<div className="rounded bg-muted p-2 text-xs"> <div className="rounded bg-muted p-2 text-xs">
<div>Child 2</div> <div>Child 2</div>
@@ -290,7 +270,7 @@ export default function SpacingPage() {
title="Use Gap, Not Margin for Buttons" title="Use Gap, Not Margin for Buttons"
description="Button groups should use gap, not margins" description="Button groups should use gap, not margins"
before={{ before={{
caption: "Margin on children - harder to maintain", caption: 'Margin on children - harder to maintain',
content: ( content: (
<div className="flex rounded-lg border p-4"> <div className="flex rounded-lg border p-4">
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
@@ -303,7 +283,7 @@ export default function SpacingPage() {
), ),
}} }}
after={{ after={{
caption: "Gap on parent - clean and flexible", caption: 'Gap on parent - clean and flexible',
content: ( content: (
<div className="flex gap-4 rounded-lg border p-4"> <div className="flex gap-4 rounded-lg border p-4">
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">

View File

@@ -20,23 +20,18 @@ export default function ForbiddenPage() {
<div className="container mx-auto px-6 py-16"> <div className="container mx-auto px-6 py-16">
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center"> <div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
<div className="mb-8 rounded-full bg-destructive/10 p-6"> <div className="mb-8 rounded-full bg-destructive/10 p-6">
<ShieldAlert <ShieldAlert className="h-16 w-16 text-destructive" aria-hidden="true" />
className="h-16 w-16 text-destructive"
aria-hidden="true"
/>
</div> </div>
<h1 className="mb-4 text-4xl font-bold tracking-tight"> <h1 className="mb-4 text-4xl font-bold tracking-tight">403 - Access Forbidden</h1>
403 - Access Forbidden
</h1>
<p className="mb-2 text-lg text-muted-foreground max-w-md"> <p className="mb-2 text-lg text-muted-foreground max-w-md">
You don&apos;t have permission to access this resource. You don&apos;t have permission to access this resource.
</p> </p>
<p className="mb-8 text-sm text-muted-foreground max-w-md"> <p className="mb-8 text-sm text-muted-foreground max-w-md">
This page requires administrator privileges. If you believe you should This page requires administrator privileges. If you believe you should have access, please
have access, please contact your system administrator. contact your system administrator.
</p> </p>
<div className="flex gap-4"> <div className="flex gap-4">

View File

@@ -1,4 +1,4 @@
@import "tailwindcss"; @import 'tailwindcss';
/** /**
* FastNext Template Design System * FastNext Template Design System
@@ -11,38 +11,38 @@
:root { :root {
/* Colors */ /* Colors */
--background: oklch(1.0000 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.3211 0 0); --foreground: oklch(0.3211 0 0);
--card: oklch(1.0000 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.3211 0 0); --card-foreground: oklch(0.3211 0 0);
--popover: oklch(1.0000 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.3211 0 0); --popover-foreground: oklch(0.3211 0 0);
--primary: oklch(0.6231 0.1880 259.8145); --primary: oklch(0.6231 0.188 259.8145);
--primary-foreground: oklch(1.0000 0 0); --primary-foreground: oklch(1 0 0);
--secondary: oklch(0.9670 0.0029 264.5419); --secondary: oklch(0.967 0.0029 264.5419);
--secondary-foreground: oklch(0.4461 0.0263 256.8018); --secondary-foreground: oklch(0.4461 0.0263 256.8018);
--muted: oklch(0.9846 0.0017 247.8389); --muted: oklch(0.9846 0.0017 247.8389);
--muted-foreground: oklch(0.5510 0.0234 264.3637); --muted-foreground: oklch(0.551 0.0234 264.3637);
--accent: oklch(0.9514 0.0250 236.8242); --accent: oklch(0.9514 0.025 236.8242);
--accent-foreground: oklch(0.3791 0.1378 265.5222); --accent-foreground: oklch(0.3791 0.1378 265.5222);
--destructive: oklch(0.6368 0.2078 25.3313); --destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0); --destructive-foreground: oklch(1 0 0);
--border: oklch(0.9276 0.0058 264.5313); --border: oklch(0.9276 0.0058 264.5313);
--input: oklch(0.9276 0.0058 264.5313); --input: oklch(0.9276 0.0058 264.5313);
--ring: oklch(0.6231 0.1880 259.8145); --ring: oklch(0.6231 0.188 259.8145);
--chart-1: oklch(0.6231 0.1880 259.8145); --chart-1: oklch(0.6231 0.188 259.8145);
--chart-2: oklch(0.5461 0.2152 262.8809); --chart-2: oklch(0.5461 0.2152 262.8809);
--chart-3: oklch(0.4882 0.2172 264.3763); --chart-3: oklch(0.4882 0.2172 264.3763);
--chart-4: oklch(0.4244 0.1809 265.6377); --chart-4: oklch(0.4244 0.1809 265.6377);
--chart-5: oklch(0.3791 0.1378 265.5222); --chart-5: oklch(0.3791 0.1378 265.5222);
--sidebar: oklch(0.9846 0.0017 247.8389); --sidebar: oklch(0.9846 0.0017 247.8389);
--sidebar-foreground: oklch(0.3211 0 0); --sidebar-foreground: oklch(0.3211 0 0);
--sidebar-primary: oklch(0.6231 0.1880 259.8145); --sidebar-primary: oklch(0.6231 0.188 259.8145);
--sidebar-primary-foreground: oklch(1.0000 0 0); --sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.9514 0.0250 236.8242); --sidebar-accent: oklch(0.9514 0.025 236.8242);
--sidebar-accent-foreground: oklch(0.3791 0.1378 265.5222); --sidebar-accent-foreground: oklch(0.3791 0.1378 265.5222);
--sidebar-border: oklch(0.9276 0.0058 264.5313); --sidebar-border: oklch(0.9276 0.0058 264.5313);
--sidebar-ring: oklch(0.6231 0.1880 259.8145); --sidebar-ring: oklch(0.6231 0.188 259.8145);
/* Typography - Use Geist fonts from Next.js */ /* Typography - Use Geist fonts from Next.js */
--font-sans: var(--font-geist-sans), system-ui, -apple-system, sans-serif; --font-sans: var(--font-geist-sans), system-ui, -apple-system, sans-serif;
@@ -61,11 +61,11 @@
--shadow-color: oklch(0 0 0); --shadow-color: oklch(0 0 0);
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --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-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.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10); --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.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10); --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.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10); --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.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10); --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-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
/* Spacing */ /* Spacing */
@@ -81,8 +81,8 @@
--card-foreground: oklch(0.9219 0 0); --card-foreground: oklch(0.9219 0 0);
--popover: oklch(0.2686 0 0); --popover: oklch(0.2686 0 0);
--popover-foreground: oklch(0.9219 0 0); --popover-foreground: oklch(0.9219 0 0);
--primary: oklch(0.6231 0.1880 259.8145); --primary: oklch(0.6231 0.188 259.8145);
--primary-foreground: oklch(1.0000 0 0); --primary-foreground: oklch(1 0 0);
--secondary: oklch(0.2686 0 0); --secondary: oklch(0.2686 0 0);
--secondary-foreground: oklch(0.9219 0 0); --secondary-foreground: oklch(0.9219 0 0);
--muted: oklch(0.2393 0 0); --muted: oklch(0.2393 0 0);
@@ -90,23 +90,23 @@
--accent: oklch(0.3791 0.1378 265.5222); --accent: oklch(0.3791 0.1378 265.5222);
--accent-foreground: oklch(0.8823 0.0571 254.1284); --accent-foreground: oklch(0.8823 0.0571 254.1284);
--destructive: oklch(0.6368 0.2078 25.3313); --destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0); --destructive-foreground: oklch(1 0 0);
--border: oklch(0.3715 0 0); --border: oklch(0.3715 0 0);
--input: oklch(0.3715 0 0); --input: oklch(0.3715 0 0);
--ring: oklch(0.6231 0.1880 259.8145); --ring: oklch(0.6231 0.188 259.8145);
--chart-1: oklch(0.7137 0.1434 254.6240); --chart-1: oklch(0.7137 0.1434 254.624);
--chart-2: oklch(0.6231 0.1880 259.8145); --chart-2: oklch(0.6231 0.188 259.8145);
--chart-3: oklch(0.5461 0.2152 262.8809); --chart-3: oklch(0.5461 0.2152 262.8809);
--chart-4: oklch(0.4882 0.2172 264.3763); --chart-4: oklch(0.4882 0.2172 264.3763);
--chart-5: oklch(0.4244 0.1809 265.6377); --chart-5: oklch(0.4244 0.1809 265.6377);
--sidebar: oklch(0.2046 0 0); --sidebar: oklch(0.2046 0 0);
--sidebar-foreground: oklch(0.9219 0 0); --sidebar-foreground: oklch(0.9219 0 0);
--sidebar-primary: oklch(0.6231 0.1880 259.8145); --sidebar-primary: oklch(0.6231 0.188 259.8145);
--sidebar-primary-foreground: oklch(1.0000 0 0); --sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.3791 0.1378 265.5222); --sidebar-accent: oklch(0.3791 0.1378 265.5222);
--sidebar-accent-foreground: oklch(0.8823 0.0571 254.1284); --sidebar-accent-foreground: oklch(0.8823 0.0571 254.1284);
--sidebar-border: oklch(0.3715 0 0); --sidebar-border: oklch(0.3715 0 0);
--sidebar-ring: oklch(0.6231 0.1880 259.8145); --sidebar-ring: oklch(0.6231 0.188 259.8145);
} }
/* Make CSS variables available to Tailwind utilities */ /* Make CSS variables available to Tailwind utilities */
@@ -186,24 +186,24 @@ html.dark {
/* Cursor pointer for all clickable elements */ /* Cursor pointer for all clickable elements */
button, button,
[role="button"], [role='button'],
[type="button"], [type='button'],
[type="submit"], [type='submit'],
[type="reset"], [type='reset'],
a, a,
label[for], label[for],
select, select,
[tabindex]:not([tabindex="-1"]) { [tabindex]:not([tabindex='-1']) {
cursor: pointer; cursor: pointer;
} }
/* Exception: disabled elements should not have pointer cursor */ /* Exception: disabled elements should not have pointer cursor */
button:disabled, button:disabled,
[role="button"][aria-disabled="true"], [role='button'][aria-disabled='true'],
[type="button"]:disabled, [type='button']:disabled,
[type="submit"]:disabled, [type='submit']:disabled,
[type="reset"]:disabled, [type='reset']:disabled,
a[aria-disabled="true"], a[aria-disabled='true'],
select:disabled { select:disabled {
cursor: not-allowed; cursor: not-allowed;
} }

View File

@@ -1,27 +1,27 @@
import type { Metadata } from "next"; import type { Metadata } from 'next';
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from 'next/font/google';
import "./globals.css"; import './globals.css';
import { Providers } from "./providers"; import { Providers } from './providers';
import { AuthProvider } from "@/lib/auth/AuthContext"; import { AuthProvider } from '@/lib/auth/AuthContext';
import { AuthInitializer } from "@/components/auth"; import { AuthInitializer } from '@/components/auth';
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: '--font-geist-sans',
subsets: ["latin"], subsets: ['latin'],
display: "swap", // Prevent font from blocking render display: 'swap', // Prevent font from blocking render
preload: true, preload: true,
}); });
const geistMono = Geist_Mono({ const geistMono = Geist_Mono({
variable: "--font-geist-mono", variable: '--font-geist-mono',
subsets: ["latin"], subsets: ['latin'],
display: "swap", // Prevent font from blocking render display: 'swap', // Prevent font from blocking render
preload: false, // Only preload primary font preload: false, // Only preload primary font
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "FastNext Template", title: 'FastNext Template',
description: "FastAPI + Next.js Template", description: 'FastAPI + Next.js Template',
}; };
export default function RootLayout({ export default function RootLayout({
@@ -57,9 +57,7 @@ export default function RootLayout({
}} }}
/> />
</head> </head>
<body <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<AuthProvider> <AuthProvider>
<AuthInitializer /> <AuthInitializer />
<Providers>{children}</Providers> <Providers>{children}</Providers>

View File

@@ -99,10 +99,7 @@ export default function Home() {
</footer> </footer>
{/* Shared Demo Credentials Modal */} {/* Shared Demo Credentials Modal */}
<DemoCredentialsModal <DemoCredentialsModal open={demoModalOpen} onClose={() => setDemoModalOpen(false)} />
open={demoModalOpen}
onClose={() => setDemoModalOpen(false)}
/>
</div> </div>
); );
} }

View File

@@ -8,8 +8,7 @@ import { ThemeProvider } from '@/components/theme';
// Set NEXT_PUBLIC_ENABLE_DEVTOOLS=true in .env.local to enable // Set NEXT_PUBLIC_ENABLE_DEVTOOLS=true in .env.local to enable
/* istanbul ignore next - Dev-only devtools, not tested in production */ /* istanbul ignore next - Dev-only devtools, not tested in production */
const ReactQueryDevtools = const ReactQueryDevtools =
process.env.NODE_ENV === 'development' && process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_ENABLE_DEVTOOLS === 'true'
process.env.NEXT_PUBLIC_ENABLE_DEVTOOLS === 'true'
? lazy(() => ? lazy(() =>
import('@tanstack/react-query-devtools').then((mod) => ({ import('@tanstack/react-query-devtools').then((mod) => ({
default: mod.ReactQueryDevtools, default: mod.ReactQueryDevtools,

View File

@@ -64,9 +64,7 @@ export function AdminSidebar() {
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Sidebar Header */} {/* Sidebar Header */}
<div className="flex h-16 items-center justify-between border-b px-4"> <div className="flex h-16 items-center justify-between border-b px-4">
{!collapsed && ( {!collapsed && <h2 className="text-lg font-semibold">Admin Panel</h2>}
<h2 className="text-lg font-semibold">Admin Panel</h2>
)}
<button <button
onClick={() => setCollapsed(!collapsed)} onClick={() => setCollapsed(!collapsed)}
className="rounded-md p-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" className="rounded-md p-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
@@ -85,8 +83,7 @@ export function AdminSidebar() {
<nav className="flex-1 space-y-1 p-2"> <nav className="flex-1 space-y-1 p-2">
{navItems.map((item) => { {navItems.map((item) => {
const isActive = const isActive =
pathname === item.href || pathname === item.href || (item.href !== '/admin' && pathname.startsWith(item.href));
(item.href !== '/admin' && pathname.startsWith(item.href));
const Icon = item.icon; const Icon = item.icon;
return ( return (
@@ -97,9 +94,7 @@ export function AdminSidebar() {
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors', 'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
'hover:bg-accent hover:text-accent-foreground', 'hover:bg-accent hover:text-accent-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isActive isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground',
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground',
collapsed && 'justify-center' collapsed && 'justify-center'
)} )}
title={collapsed ? item.name : undefined} title={collapsed ? item.name : undefined}
@@ -123,9 +118,7 @@ export function AdminSidebar() {
<p className="text-sm font-medium truncate"> <p className="text-sm font-medium truncate">
{user.first_name} {user.last_name} {user.first_name} {user.last_name}
</p> </p>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">{user.email}</p>
{user.email}
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -61,10 +61,7 @@ export function Breadcrumbs() {
return ( return (
<li key={breadcrumb.href} className="flex items-center"> <li key={breadcrumb.href} className="flex items-center">
{index > 0 && ( {index > 0 && (
<ChevronRight <ChevronRight className="mx-2 h-4 w-4 text-muted-foreground" aria-hidden="true" />
className="mx-2 h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
)} )}
{isLast ? ( {isLast ? (
<span <span

View File

@@ -26,10 +26,7 @@ export function DashboardStats() {
} }
return ( return (
<div <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4" data-testid="dashboard-stats">
className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"
data-testid="dashboard-stats"
>
<StatCard <StatCard
title="Total Users" title="Total Users"
value={stats?.totalUsers ?? 0} value={stats?.totalUsers ?? 0}

View File

@@ -40,29 +40,20 @@ export function StatCard({
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-1 flex-1"> <div className="space-y-1 flex-1">
<p <p className="text-sm font-medium text-muted-foreground" data-testid="stat-title">
className="text-sm font-medium text-muted-foreground"
data-testid="stat-title"
>
{title} {title}
</p> </p>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
{loading ? ( {loading ? (
<div className="h-8 w-24 bg-muted rounded" /> <div className="h-8 w-24 bg-muted rounded" />
) : ( ) : (
<p <p className="text-3xl font-bold tracking-tight" data-testid="stat-value">
className="text-3xl font-bold tracking-tight"
data-testid="stat-value"
>
{value} {value}
</p> </p>
)} )}
</div> </div>
{description && !loading && ( {description && !loading && (
<p <p className="text-xs text-muted-foreground" data-testid="stat-description">
className="text-xs text-muted-foreground"
data-testid="stat-description"
>
{description} {description}
</p> </p>
)} )}
@@ -74,22 +65,13 @@ export function StatCard({
)} )}
data-testid="stat-trend" data-testid="stat-trend"
> >
{trend.isPositive ? '↑' : '↓'} {Math.abs(trend.value)}%{' '} {trend.isPositive ? '↑' : '↓'} {Math.abs(trend.value)}% {trend.label}
{trend.label}
</div> </div>
)} )}
</div> </div>
<div <div className={cn('rounded-full p-3', loading ? 'bg-muted' : 'bg-primary/10')}>
className={cn(
'rounded-full p-3',
loading ? 'bg-muted' : 'bg-primary/10'
)}
>
<Icon <Icon
className={cn( className={cn('h-6 w-6', loading ? 'text-muted-foreground' : 'text-primary')}
'h-6 w-6',
loading ? 'text-muted-foreground' : 'text-primary'
)}
aria-hidden="true" aria-hidden="true"
/> />
</div> </div>

View File

@@ -47,11 +47,7 @@ interface AddMemberDialogProps {
organizationId: string; organizationId: string;
} }
export function AddMemberDialog({ export function AddMemberDialog({ open, onOpenChange, organizationId }: AddMemberDialogProps) {
open,
onOpenChange,
organizationId,
}: AddMemberDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Fetch all users for the dropdown (simplified - in production, use search/autocomplete) // Fetch all users for the dropdown (simplified - in production, use search/autocomplete)
@@ -69,7 +65,12 @@ export function AddMemberDialog({
}, },
}); });
const { handleSubmit, formState: { errors }, setValue, watch } = form; const {
handleSubmit,
formState: { errors },
setValue,
watch,
} = form;
const selectedRole = watch('role'); const selectedRole = watch('role');
const selectedEmail = watch('userEmail'); const selectedEmail = watch('userEmail');
@@ -139,7 +140,12 @@ export function AddMemberDialog({
{/* Role Select */} {/* Role Select */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="role">Role *</Label> <Label htmlFor="role">Role *</Label>
<Select value={selectedRole} onValueChange={(value) => setValue('role', value as 'owner' | 'admin' | 'member' | 'guest')}> <Select
value={selectedRole}
onValueChange={(value) =>
setValue('role', value as 'owner' | 'admin' | 'member' | 'guest')
}
>
<SelectTrigger id="role"> <SelectTrigger id="role">
<SelectValue placeholder="Select a role" /> <SelectValue placeholder="Select a role" />
</SelectTrigger> </SelectTrigger>
@@ -150,9 +156,7 @@ export function AddMemberDialog({
<SelectItem value="guest">Guest</SelectItem> <SelectItem value="guest">Guest</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{errors.role && ( {errors.role && <p className="text-sm text-destructive">{errors.role.message}</p>}
<p className="text-sm text-destructive">{errors.role.message}</p>
)}
</div> </div>
{/* Actions */} {/* Actions */}

View File

@@ -25,20 +25,14 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import { useRemoveOrganizationMember, type OrganizationMember } from '@/lib/api/hooks/useAdmin';
useRemoveOrganizationMember,
type OrganizationMember,
} from '@/lib/api/hooks/useAdmin';
interface MemberActionMenuProps { interface MemberActionMenuProps {
member: OrganizationMember; member: OrganizationMember;
organizationId: string; organizationId: string;
} }
export function MemberActionMenu({ export function MemberActionMenu({ member, organizationId }: MemberActionMenuProps) {
member,
organizationId,
}: MemberActionMenuProps) {
const [confirmRemove, setConfirmRemove] = useState(false); const [confirmRemove, setConfirmRemove] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
@@ -59,9 +53,8 @@ export function MemberActionMenu({
} }
}; };
const memberName = [member.first_name, member.last_name] const memberName =
.filter(Boolean) [member.first_name, member.last_name].filter(Boolean).join(' ') || member.email;
.join(' ') || member.email;
return ( return (
<> <>
@@ -93,8 +86,8 @@ export function MemberActionMenu({
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Remove Member</AlertDialogTitle> <AlertDialogTitle>Remove Member</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to remove {memberName} from this organization? Are you sure you want to remove {memberName} from this organization? This action
This action cannot be undone. cannot be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>

View File

@@ -26,10 +26,7 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import { useDeleteOrganization, type Organization } from '@/lib/api/hooks/useAdmin';
useDeleteOrganization,
type Organization,
} from '@/lib/api/hooks/useAdmin';
interface OrganizationActionMenuProps { interface OrganizationActionMenuProps {
organization: Organization; organization: Organization;
@@ -115,8 +112,8 @@ export function OrganizationActionMenu({
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete Organization</AlertDialogTitle> <AlertDialogTitle>Delete Organization</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete {organization.name}? This action cannot be undone Are you sure you want to delete {organization.name}? This action cannot be undone and
and will remove all associated data. will remove all associated data.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>

View File

@@ -112,7 +112,10 @@ export function OrganizationFormDialog({
toast.success(`${data.name} has been updated successfully.`); toast.success(`${data.name} has been updated successfully.`);
} else { } else {
// Generate slug from name (simple kebab-case conversion) // Generate slug from name (simple kebab-case conversion)
const slug = data.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); const slug = data.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
await createOrganization.mutateAsync({ await createOrganization.mutateAsync({
name: data.name, name: data.name,
@@ -125,7 +128,9 @@ export function OrganizationFormDialog({
form.reset(); form.reset();
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error ? error.message : `Failed to ${isEdit ? 'update' : 'create'} organization` error instanceof Error
? error.message
: `Failed to ${isEdit ? 'update' : 'create'} organization`
); );
} }
}; };
@@ -137,9 +142,7 @@ export function OrganizationFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[500px]">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>{isEdit ? 'Edit Organization' : 'Create Organization'}</DialogTitle>
{isEdit ? 'Edit Organization' : 'Create Organization'}
</DialogTitle>
<DialogDescription> <DialogDescription>
{isEdit {isEdit
? 'Update the organization details below.' ? 'Update the organization details below.'
@@ -189,15 +192,10 @@ export function OrganizationFormDialog({
<Checkbox <Checkbox
id="is_active" id="is_active"
checked={form.watch('is_active')} checked={form.watch('is_active')}
onCheckedChange={(checked) => onCheckedChange={(checked) => form.setValue('is_active', checked === true)}
form.setValue('is_active', checked === true)
}
disabled={isLoading} disabled={isLoading}
/> />
<Label <Label htmlFor="is_active" className="text-sm font-normal cursor-pointer">
htmlFor="is_active"
className="text-sm font-normal cursor-pointer"
>
Organization is active Organization is active
</Label> </Label>
</div> </div>

View File

@@ -93,9 +93,7 @@ export function OrganizationListTable({
<TableCell className="font-medium">{org.name}</TableCell> <TableCell className="font-medium">{org.name}</TableCell>
<TableCell className="max-w-md truncate"> <TableCell className="max-w-md truncate">
{org.description || ( {org.description || (
<span className="text-muted-foreground italic"> <span className="text-muted-foreground italic">No description</span>
No description
</span>
)} )}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
@@ -112,9 +110,7 @@ export function OrganizationListTable({
{org.is_active ? 'Active' : 'Inactive'} {org.is_active ? 'Active' : 'Inactive'}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>{format(new Date(org.created_at), 'MMM d, yyyy')}</TableCell>
{format(new Date(org.created_at), 'MMM d, yyyy')}
</TableCell>
<TableCell> <TableCell>
<OrganizationActionMenu <OrganizationActionMenu
organization={org} organization={org}
@@ -135,11 +131,8 @@ export function OrganizationListTable({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.page_size + 1} to{' '} Showing {(pagination.page - 1) * pagination.page_size + 1} to{' '}
{Math.min( {Math.min(pagination.page * pagination.page_size, pagination.total)} of{' '}
pagination.page * pagination.page_size, {pagination.total} organizations
pagination.total
)}{' '}
of {pagination.total} organizations
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@@ -164,13 +157,9 @@ export function OrganizationListTable({
return ( return (
<div key={page} className="flex items-center"> <div key={page} className="flex items-center">
{showEllipsis && ( {showEllipsis && <span className="px-2 text-muted-foreground">...</span>}
<span className="px-2 text-muted-foreground">...</span>
)}
<Button <Button
variant={ variant={page === pagination.page ? 'default' : 'outline'}
page === pagination.page ? 'default' : 'outline'
}
size="sm" size="sm"
onClick={() => onPageChange(page)} onClick={() => onPageChange(page)}
className="w-9" className="w-9"

View File

@@ -89,9 +89,7 @@ export function OrganizationManagementContent() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight">All Organizations</h2> <h2 className="text-2xl font-bold tracking-tight">All Organizations</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">Manage organizations and their members</p>
Manage organizations and their members
</p>
</div> </div>
<Button onClick={handleCreateOrganization}> <Button onClick={handleCreateOrganization}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />

View File

@@ -85,9 +85,7 @@ export function OrganizationMembersContent({ organizationId }: OrganizationMembe
{/* Header with Add Member Button */} {/* Header with Add Member Button */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight"> <h2 className="text-2xl font-bold tracking-tight">{orgName} Members</h2>
{orgName} Members
</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Manage members and their roles within the organization Manage members and their roles within the organization
</p> </p>

View File

@@ -119,14 +119,9 @@ export function OrganizationMembersTable({
{formatRole(member.role)} {formatRole(member.role)}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>{format(new Date(member.joined_at), 'MMM d, yyyy')}</TableCell>
<TableCell> <TableCell>
{format(new Date(member.joined_at), 'MMM d, yyyy')} <MemberActionMenu member={member} organizationId={organizationId} />
</TableCell>
<TableCell>
<MemberActionMenu
member={member}
organizationId={organizationId}
/>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
@@ -141,11 +136,8 @@ export function OrganizationMembersTable({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.page_size + 1} to{' '} Showing {(pagination.page - 1) * pagination.page_size + 1} to{' '}
{Math.min( {Math.min(pagination.page * pagination.page_size, pagination.total)} of{' '}
pagination.page * pagination.page_size, {pagination.total} members
pagination.total
)}{' '}
of {pagination.total} members
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@@ -170,13 +162,9 @@ export function OrganizationMembersTable({
return ( return (
<div key={page} className="flex items-center"> <div key={page} className="flex items-center">
{showEllipsis && ( {showEllipsis && <span className="px-2 text-muted-foreground">...</span>}
<span className="px-2 text-muted-foreground">...</span>
)}
<Button <Button
variant={ variant={page === pagination.page ? 'default' : 'outline'}
page === pagination.page ? 'default' : 'outline'
}
size="sm" size="sm"
onClick={() => onPageChange(page)} onClick={() => onPageChange(page)}
className="w-9" className="w-9"

View File

@@ -62,9 +62,7 @@ export function BulkActionToolbar({
onClearSelection(); onClearSelection();
setPendingAction(null); setPendingAction(null);
} catch (error) { } catch (error) {
toast.error( toast.error(error instanceof Error ? error.message : `Failed to ${pendingAction} users`);
error instanceof Error ? error.message : `Failed to ${pendingAction} users`
);
setPendingAction(null); setPendingAction(null);
} }
}; };
@@ -161,9 +159,7 @@ export function BulkActionToolbar({
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{getActionTitle()}</AlertDialogTitle> <AlertDialogTitle>{getActionTitle()}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>{getActionDescription()}</AlertDialogDescription>
{getActionDescription()}
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>

View File

@@ -49,9 +49,7 @@ export function UserActionMenu({ user, isCurrentUser, onEdit }: UserActionMenuPr
const deactivateUser = useDeactivateUser(); const deactivateUser = useDeactivateUser();
const deleteUser = useDeleteUser(); const deleteUser = useDeleteUser();
const fullName = user.last_name const fullName = user.last_name ? `${user.first_name} ${user.last_name}` : user.first_name;
? `${user.first_name} ${user.last_name}`
: user.first_name;
// Handle activate action // Handle activate action
const handleActivate = async () => { const handleActivate = async () => {

View File

@@ -23,21 +23,14 @@ import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Alert } from '@/components/ui/alert'; import { Alert } from '@/components/ui/alert';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import { useCreateUser, useUpdateUser, type User } from '@/lib/api/hooks/useAdmin';
useCreateUser,
useUpdateUser,
type User,
} from '@/lib/api/hooks/useAdmin';
// ============================================================================ // ============================================================================
// Validation Schema // Validation Schema
// ============================================================================ // ============================================================================
const userFormSchema = z.object({ const userFormSchema = z.object({
email: z email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
first_name: z first_name: z
.string() .string()
.min(1, 'First name is required') .min(1, 'First name is required')
@@ -66,12 +59,7 @@ interface UserFormDialogProps {
mode: 'create' | 'edit'; mode: 'create' | 'edit';
} }
export function UserFormDialog({ export function UserFormDialog({ open, onOpenChange, user, mode }: UserFormDialogProps) {
open,
onOpenChange,
user,
mode,
}: UserFormDialogProps) {
const isEdit = mode === 'edit' && user; const isEdit = mode === 'edit' && user;
const createUser = useCreateUser(); const createUser = useCreateUser();
const updateUser = useUpdateUser(); const updateUser = useUpdateUser();
@@ -130,7 +118,9 @@ export function UserFormDialog({
return; return;
} }
if (!/[A-Z]/.test(data.password)) { if (!/[A-Z]/.test(data.password)) {
form.setError('password', { message: 'Password must contain at least one uppercase letter' }); form.setError('password', {
message: 'Password must contain at least one uppercase letter',
});
return; return;
} }
} }
@@ -147,7 +137,9 @@ export function UserFormDialog({
return; return;
} }
if (!/[A-Z]/.test(data.password)) { if (!/[A-Z]/.test(data.password)) {
form.setError('password', { message: 'Password must contain at least one uppercase letter' }); form.setError('password', {
message: 'Password must contain at least one uppercase letter',
});
return; return;
} }
} }
@@ -305,10 +297,7 @@ export function UserFormDialog({
onCheckedChange={(checked) => setValue('is_active', checked as boolean)} onCheckedChange={(checked) => setValue('is_active', checked as boolean)}
disabled={isSubmitting} disabled={isSubmitting}
/> />
<Label <Label htmlFor="is_active" className="text-sm font-normal cursor-pointer">
htmlFor="is_active"
className="text-sm font-normal cursor-pointer"
>
Active (user can log in) Active (user can log in)
</Label> </Label>
</div> </div>
@@ -320,10 +309,7 @@ export function UserFormDialog({
onCheckedChange={(checked) => setValue('is_superuser', checked as boolean)} onCheckedChange={(checked) => setValue('is_superuser', checked as boolean)}
disabled={isSubmitting} disabled={isSubmitting}
/> />
<Label <Label htmlFor="is_superuser" className="text-sm font-normal cursor-pointer">
htmlFor="is_superuser"
className="text-sm font-normal cursor-pointer"
>
Superuser (admin privileges) Superuser (admin privileges)
</Label> </Label>
</div> </div>
@@ -335,8 +321,8 @@ export function UserFormDialog({
{createUser.isError && createUser.error instanceof Error {createUser.isError && createUser.error instanceof Error
? createUser.error.message ? createUser.error.message
: updateUser.error instanceof Error : updateUser.error instanceof Error
? updateUser.error.message ? updateUser.error.message
: 'An error occurred'} : 'An error occurred'}
</Alert> </Alert>
)} )}
@@ -355,8 +341,8 @@ export function UserFormDialog({
? 'Updating...' ? 'Updating...'
: 'Creating...' : 'Creating...'
: isEdit : isEdit
? 'Update User' ? 'Update User'
: 'Create User'} : 'Create User'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>

View File

@@ -74,8 +74,7 @@ export function UserListTable({
[onSearch] [onSearch]
); );
const allSelected = const allSelected = users.length > 0 && users.every((user) => selectedUsers.includes(user.id));
users.length > 0 && users.every((user) => selectedUsers.includes(user.id));
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -195,28 +194,18 @@ export function UserListTable({
</TableCell> </TableCell>
<TableCell>{user.email}</TableCell> <TableCell>{user.email}</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<Badge <Badge variant={user.is_active ? 'default' : 'secondary'}>
variant={user.is_active ? 'default' : 'secondary'}
>
{user.is_active ? 'Active' : 'Inactive'} {user.is_active ? 'Active' : 'Inactive'}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
{user.is_superuser ? ( {user.is_superuser ? (
<Check <Check className="h-4 w-4 mx-auto text-green-600" aria-label="Yes" />
className="h-4 w-4 mx-auto text-green-600"
aria-label="Yes"
/>
) : ( ) : (
<X <X className="h-4 w-4 mx-auto text-muted-foreground" aria-label="No" />
className="h-4 w-4 mx-auto text-muted-foreground"
aria-label="No"
/>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>{format(new Date(user.created_at), 'MMM d, yyyy')}</TableCell>
{format(new Date(user.created_at), 'MMM d, yyyy')}
</TableCell>
<TableCell> <TableCell>
<UserActionMenu <UserActionMenu
user={user} user={user}
@@ -237,11 +226,8 @@ export function UserListTable({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.page_size + 1} to{' '} Showing {(pagination.page - 1) * pagination.page_size + 1} to{' '}
{Math.min( {Math.min(pagination.page * pagination.page_size, pagination.total)} of{' '}
pagination.page * pagination.page_size, {pagination.total} users
pagination.total
)}{' '}
of {pagination.total} users
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@@ -266,13 +252,9 @@ export function UserListTable({
return ( return (
<div key={page} className="flex items-center"> <div key={page} className="flex items-center">
{showEllipsis && ( {showEllipsis && <span className="px-2 text-muted-foreground">...</span>}
<span className="px-2 text-muted-foreground">...</span>
)}
<Button <Button
variant={ variant={page === pagination.page ? 'default' : 'outline'}
page === pagination.page ? 'default' : 'outline'
}
size="sm" size="sm"
onClick={() => onPageChange(page)} onClick={() => onPageChange(page)}
className="w-9" className="w-9"

View File

@@ -28,7 +28,8 @@ export function UserManagementContent() {
// Convert filter strings to booleans for API // Convert filter strings to booleans for API
const isActiveFilter = filterActive === 'true' ? true : filterActive === 'false' ? false : null; const isActiveFilter = filterActive === 'true' ? true : filterActive === 'false' ? false : null;
const isSuperuserFilter = filterSuperuser === 'true' ? true : filterSuperuser === 'false' ? false : null; const isSuperuserFilter =
filterSuperuser === 'true' ? true : filterSuperuser === 'false' ? false : null;
// Local state // Local state
const [selectedUsers, setSelectedUsers] = useState<string[]>([]); const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
@@ -85,9 +86,7 @@ export function UserManagementContent() {
// istanbul ignore next - Event handlers fully tested in E2E (admin-users.spec.ts) // istanbul ignore next - Event handlers fully tested in E2E (admin-users.spec.ts)
const handleSelectAll = (selected: boolean) => { const handleSelectAll = (selected: boolean) => {
if (selected) { if (selected) {
const selectableUsers = users const selectableUsers = users.filter((u) => u.id !== currentUser?.id).map((u) => u.id);
.filter((u) => u.id !== currentUser?.id)
.map((u) => u.id);
setSelectedUsers(selectableUsers); setSelectedUsers(selectableUsers);
} else { } else {
setSelectedUsers([]); setSelectedUsers([]);
@@ -141,9 +140,7 @@ export function UserManagementContent() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight">All Users</h2> <h2 className="text-2xl font-bold tracking-tight">All Users</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">Manage user accounts and permissions</p>
Manage user accounts and permissions
</p>
</div> </div>
<Button onClick={handleCreateUser}> <Button onClick={handleCreateUser}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />

View File

@@ -83,9 +83,8 @@ export function AuthGuard({ children, requireAdmin = false, fallback }: AuthGuar
// If not loading and not authenticated, redirect to login // If not loading and not authenticated, redirect to login
if (!isLoading && !isAuthenticated) { if (!isLoading && !isAuthenticated) {
// Preserve intended destination // Preserve intended destination
const returnUrl = pathname !== config.routes.login const returnUrl =
? `?returnUrl=${encodeURIComponent(pathname)}` pathname !== config.routes.login ? `?returnUrl=${encodeURIComponent(pathname)}` : '';
: '';
router.push(`${config.routes.login}${returnUrl}`); router.push(`${config.routes.login}${returnUrl}`);
} }

View File

@@ -21,9 +21,7 @@ export function AuthLayoutClient({ children }: AuthLayoutClientProps) {
{/* Auth card */} {/* Auth card */}
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="rounded-lg border bg-card p-8 shadow-sm"> <div className="rounded-lg border bg-card p-8 shadow-sm">{children}</div>
{children}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -24,10 +24,7 @@ import config from '@/config/app.config';
// ============================================================================ // ============================================================================
const loginSchema = z.object({ const loginSchema = z.object({
email: z email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
password: z password: z
.string() .string()
.min(1, 'Password is required') .min(1, 'Password is required')
@@ -187,11 +184,7 @@ export function LoginForm({
</div> </div>
{/* Submit Button */} {/* Submit Button */}
<Button <Button type="submit" className="w-full" disabled={isSubmitting}>
type="submit"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting ? 'Signing in...' : 'Sign in'} {isSubmitting ? 'Signing in...' : 'Sign in'}
</Button> </Button>

View File

@@ -57,8 +57,7 @@ function calculatePasswordStrength(password: string): {
const hasNumber = /[0-9]/.test(password); const hasNumber = /[0-9]/.test(password);
const hasUppercase = /[A-Z]/.test(password); const hasUppercase = /[A-Z]/.test(password);
const strength = const strength = (hasMinLength ? 33 : 0) + (hasNumber ? 33 : 0) + (hasUppercase ? 34 : 0);
(hasMinLength ? 33 : 0) + (hasNumber ? 33 : 0) + (hasUppercase ? 34 : 0);
return { hasMinLength, hasNumber, hasUppercase, strength }; return { hasMinLength, hasNumber, hasUppercase, strength };
} }
@@ -208,9 +207,7 @@ export function PasswordResetConfirmForm({
{...form.register('new_password')} {...form.register('new_password')}
aria-invalid={!!form.formState.errors.new_password} aria-invalid={!!form.formState.errors.new_password}
aria-describedby={ aria-describedby={
form.formState.errors.new_password form.formState.errors.new_password ? 'new-password-error' : 'password-requirements'
? 'new-password-error'
: 'password-requirements'
} }
aria-required="true" aria-required="true"
/> />
@@ -261,8 +258,7 @@ export function PasswordResetConfirmForm({
: 'text-muted-foreground' : 'text-muted-foreground'
} }
> >
{passwordStrength.hasUppercase ? '✓' : '○'} Contains an uppercase {passwordStrength.hasUppercase ? '✓' : '○'} Contains an uppercase letter
letter
</li> </li>
</ul> </ul>
</div> </div>
@@ -283,9 +279,7 @@ export function PasswordResetConfirmForm({
{...form.register('confirm_password')} {...form.register('confirm_password')}
aria-invalid={!!form.formState.errors.confirm_password} aria-invalid={!!form.formState.errors.confirm_password}
aria-describedby={ aria-describedby={
form.formState.errors.confirm_password form.formState.errors.confirm_password ? 'confirm-password-error' : undefined
? 'confirm-password-error'
: undefined
} }
aria-required="true" aria-required="true"
/> />

View File

@@ -23,10 +23,7 @@ import { getGeneralError, getFieldErrors, isAPIErrorArray } from '@/lib/api/erro
// ============================================================================ // ============================================================================
const resetRequestSchema = z.object({ const resetRequestSchema = z.object({
email: z email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
}); });
type ResetRequestFormData = z.infer<typeof resetRequestSchema>; type ResetRequestFormData = z.infer<typeof resetRequestSchema>;
@@ -169,11 +166,7 @@ export function PasswordResetRequestForm({
</div> </div>
{/* Submit Button */} {/* Submit Button */}
<Button <Button type="submit" className="w-full" disabled={isSubmitting}>
type="submit"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting ? 'Sending...' : 'Send Reset Instructions'} {isSubmitting ? 'Sending...' : 'Send Reset Instructions'}
</Button> </Button>

View File

@@ -25,10 +25,7 @@ import config from '@/config/app.config';
const registerSchema = z const registerSchema = z
.object({ .object({
email: z email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
first_name: z first_name: z
.string() .string()
.min(1, 'First name is required') .min(1, 'First name is required')
@@ -45,9 +42,7 @@ const registerSchema = z
.min(8, 'Password must be at least 8 characters') .min(8, 'Password must be at least 8 characters')
.regex(/[0-9]/, 'Password must contain at least one number') .regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter'), .regex(/[A-Z]/, 'Password must contain at least one uppercase letter'),
confirmPassword: z confirmPassword: z.string().min(1, 'Please confirm your password'),
.string()
.min(1, 'Please confirm your password'),
}) })
.refine((data) => data.password === data.confirmPassword, { .refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match', message: 'Passwords do not match',
@@ -88,11 +83,7 @@ interface RegisterFormProps {
* /> * />
* ``` * ```
*/ */
export function RegisterForm({ export function RegisterForm({ onSuccess, showLoginLink = true, className }: RegisterFormProps) {
onSuccess,
showLoginLink = true,
className,
}: RegisterFormProps) {
const [serverError, setServerError] = useState<string | null>(null); const [serverError, setServerError] = useState<string | null>(null);
const registerMutation = useRegister(); const registerMutation = useRegister();
@@ -242,7 +233,11 @@ export function RegisterForm({
disabled={isSubmitting} disabled={isSubmitting}
{...form.register('password')} {...form.register('password')}
aria-invalid={!!form.formState.errors.password} aria-invalid={!!form.formState.errors.password}
aria-describedby={form.formState.errors.password ? 'password-error password-requirements' : 'password-requirements'} aria-describedby={
form.formState.errors.password
? 'password-error password-requirements'
: 'password-requirements'
}
/> />
{form.formState.errors.password && ( {form.formState.errors.password && (
<p id="password-error" className="text-sm text-destructive"> <p id="password-error" className="text-sm text-destructive">
@@ -253,13 +248,25 @@ export function RegisterForm({
{/* Password Strength Indicator */} {/* Password Strength Indicator */}
{password.length > 0 && !form.formState.errors.password && ( {password.length > 0 && !form.formState.errors.password && (
<div id="password-requirements" className="space-y-1 text-xs"> <div id="password-requirements" className="space-y-1 text-xs">
<p className={hasMinLength ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}> <p
className={
hasMinLength ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
}
>
{hasMinLength ? '✓' : '○'} At least 8 characters {hasMinLength ? '✓' : '○'} At least 8 characters
</p> </p>
<p className={hasNumber ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}> <p
className={
hasNumber ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
}
>
{hasNumber ? '✓' : '○'} Contains a number {hasNumber ? '✓' : '○'} Contains a number
</p> </p>
<p className={hasUppercase ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}> <p
className={
hasUppercase ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
}
>
{hasUppercase ? '✓' : '○'} Contains an uppercase letter {hasUppercase ? '✓' : '○'} Contains an uppercase letter
</p> </p>
</div> </div>
@@ -279,7 +286,9 @@ export function RegisterForm({
disabled={isSubmitting} disabled={isSubmitting}
{...form.register('confirmPassword')} {...form.register('confirmPassword')}
aria-invalid={!!form.formState.errors.confirmPassword} aria-invalid={!!form.formState.errors.confirmPassword}
aria-describedby={form.formState.errors.confirmPassword ? 'confirmPassword-error' : undefined} aria-describedby={
form.formState.errors.confirmPassword ? 'confirmPassword-error' : undefined
}
/> />
{form.formState.errors.confirmPassword && ( {form.formState.errors.confirmPassword && (
<p id="confirmPassword-error" className="text-sm text-destructive"> <p id="confirmPassword-error" className="text-sm text-destructive">
@@ -289,11 +298,7 @@ export function RegisterForm({
</div> </div>
{/* Submit Button */} {/* Submit Button */}
<Button <Button type="submit" className="w-full" disabled={isSubmitting}>
type="submit"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting ? 'Creating account...' : 'Create account'} {isSubmitting ? 'Creating account...' : 'Create account'}
</Button> </Button>

View File

@@ -5,7 +5,16 @@
'use client'; 'use client';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import { ChartCard } from './ChartCard'; import { ChartCard } from './ChartCard';
import { CHART_PALETTES } from '@/lib/chart-colors'; import { CHART_PALETTES } from '@/lib/chart-colors';

View File

@@ -5,7 +5,16 @@
'use client'; 'use client';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import { ChartCard } from './ChartCard'; import { ChartCard } from './ChartCard';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import { CHART_PALETTES } from '@/lib/chart-colors'; import { CHART_PALETTES } from '@/lib/chart-colors';

View File

@@ -5,7 +5,16 @@
'use client'; 'use client';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import { ChartCard } from './ChartCard'; import { ChartCard } from './ChartCard';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import { CHART_PALETTES } from '@/lib/chart-colors'; import { CHART_PALETTES } from '@/lib/chart-colors';

View File

@@ -61,19 +61,12 @@ export function BeforeAfter({
{(title || description) && ( {(title || description) && (
<div className="space-y-2"> <div className="space-y-2">
{title && <h3 className="text-xl font-semibold">{title}</h3>} {title && <h3 className="text-xl font-semibold">{title}</h3>}
{description && ( {description && <p className="text-sm text-muted-foreground">{description}</p>}
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div> </div>
)} )}
{/* Comparison Grid */} {/* Comparison Grid */}
<div <div className={cn('grid gap-4', vertical ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-2')}>
className={cn(
'grid gap-4',
vertical ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-2'
)}
>
{/* Before (Anti-pattern) */} {/* Before (Anti-pattern) */}
<Card className="border-destructive/50"> <Card className="border-destructive/50">
<CardHeader className="space-y-2 pb-4"> <CardHeader className="space-y-2 pb-4">
@@ -94,9 +87,7 @@ export function BeforeAfter({
</div> </div>
{/* Caption */} {/* Caption */}
{before.caption && ( {before.caption && (
<p className="text-xs text-muted-foreground italic"> <p className="text-xs text-muted-foreground italic">{before.caption}</p>
{before.caption}
</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -124,9 +115,7 @@ export function BeforeAfter({
</div> </div>
{/* Caption */} {/* Caption */}
{after.caption && ( {after.caption && (
<p className="text-xs text-muted-foreground italic"> <p className="text-xs text-muted-foreground italic">{after.caption}</p>
{after.caption}
</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -61,12 +61,8 @@ export function CodeSnippet({
{(title || language) && ( {(title || language) && (
<div className="flex items-center justify-between rounded-t-lg border border-b-0 bg-muted/50 px-4 py-2"> <div className="flex items-center justify-between rounded-t-lg border border-b-0 bg-muted/50 px-4 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{title && ( {title && <span className="text-sm font-medium text-foreground">{title}</span>}
<span className="text-sm font-medium text-foreground">{title}</span> {language && <span className="text-xs text-muted-foreground">({language})</span>}
)}
{language && (
<span className="text-xs text-muted-foreground">({language})</span>
)}
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@@ -139,8 +135,7 @@ export function CodeSnippet({
key={idx} key={idx}
className={cn( className={cn(
'leading-6', 'leading-6',
highlightLines.includes(idx + 1) && highlightLines.includes(idx + 1) && 'bg-accent/20 -mx-4 px-4'
'bg-accent/20 -mx-4 px-4'
)} )}
> >
{line || ' '} {line || ' '}

View File

@@ -9,11 +9,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { import { Mail, User, Settings, LogOut, Shield, AlertCircle, Info, Trash2 } from 'lucide-react';
Mail, User,
Settings, LogOut, Shield, AlertCircle, Info,
Trash2
} from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Card, Card,
@@ -281,7 +277,11 @@ export function ComponentShowcase() {
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox id="terms" checked={checked} onCheckedChange={(value) => setChecked(value as boolean)} /> <Checkbox
id="terms"
checked={checked}
onCheckedChange={(value) => setChecked(value as boolean)}
/>
<Label htmlFor="terms" className="text-sm font-normal cursor-pointer"> <Label htmlFor="terms" className="text-sm font-normal cursor-pointer">
Accept terms and conditions Accept terms and conditions
</Label> </Label>
@@ -357,11 +357,7 @@ export function ComponentShowcase() {
</ExampleSection> </ExampleSection>
{/* Badges */} {/* Badges */}
<ExampleSection <ExampleSection id="badges" title="Badges" description="Status indicators and labels">
id="badges"
title="Badges"
description="Status indicators and labels"
>
<Example <Example
title="Badge Variants" title="Badge Variants"
code={`<Badge>Default</Badge> code={`<Badge>Default</Badge>
@@ -411,11 +407,7 @@ export function ComponentShowcase() {
</ExampleSection> </ExampleSection>
{/* Alerts */} {/* Alerts */}
<ExampleSection <ExampleSection id="alerts" title="Alerts" description="Contextual feedback messages">
id="alerts"
title="Alerts"
description="Contextual feedback messages"
>
<div className="space-y-4"> <div className="space-y-4">
<Example <Example
title="Alert Variants" title="Alert Variants"
@@ -439,17 +431,13 @@ export function ComponentShowcase() {
<Alert> <Alert>
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<AlertTitle>Information</AlertTitle> <AlertTitle>Information</AlertTitle>
<AlertDescription> <AlertDescription>This is an informational alert message.</AlertDescription>
This is an informational alert message.
</AlertDescription>
</Alert> </Alert>
<Alert variant="destructive"> <Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle> <AlertTitle>Error</AlertTitle>
<AlertDescription> <AlertDescription>Something went wrong. Please try again.</AlertDescription>
Something went wrong. Please try again.
</AlertDescription>
</Alert> </Alert>
</div> </div>
</Example> </Example>
@@ -545,8 +533,8 @@ export function ComponentShowcase() {
<DialogHeader> <DialogHeader>
<DialogTitle>Are you sure?</DialogTitle> <DialogTitle>Are you sure?</DialogTitle>
<DialogDescription> <DialogDescription>
This action cannot be undone. This will permanently delete your account This action cannot be undone. This will permanently delete your account and
and remove your data from our servers. remove your data from our servers.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@@ -594,9 +582,7 @@ export function ComponentShowcase() {
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="password" className="space-y-4 mt-4"> <TabsContent value="password" className="space-y-4 mt-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Change your password here.</p>
Change your password here.
</p>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="current">Current Password</Label> <Label htmlFor="current">Current Password</Label>
<Input id="current" type="password" /> <Input id="current" type="password" />
@@ -607,11 +593,7 @@ export function ComponentShowcase() {
</ExampleSection> </ExampleSection>
{/* Table */} {/* Table */}
<ExampleSection <ExampleSection id="table" title="Table" description="Data tables with headers and cells">
id="table"
title="Table"
description="Data tables with headers and cells"
>
<Example <Example
title="Table Example" title="Table Example"
code={`<Table> code={`<Table>

View File

@@ -24,10 +24,7 @@ interface DevBreadcrumbsProps {
export function DevBreadcrumbs({ items, className }: DevBreadcrumbsProps) { export function DevBreadcrumbs({ items, className }: DevBreadcrumbsProps) {
return ( return (
<nav <nav className={cn('bg-muted/30 border-b', className)} aria-label="Breadcrumb">
className={cn('bg-muted/30 border-b', className)}
aria-label="Breadcrumb"
>
<div className="container mx-auto px-4 py-3"> <div className="container mx-auto px-4 py-3">
<ol className="flex items-center gap-2 text-sm"> <ol className="flex items-center gap-2 text-sm">
{/* Home link */} {/* Home link */}

Some files were not shown because too many files have changed in this diff Show More