forked from cardosofelipe/fast-next-template
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:
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"all": true,
|
||||
"include": [
|
||||
"src/**/*.{js,jsx,ts,tsx}"
|
||||
],
|
||||
"include": ["src/**/*.{js,jsx,ts,tsx}"],
|
||||
"exclude": [
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.test.{js,jsx,ts,tsx}",
|
||||
@@ -16,13 +14,7 @@
|
||||
"src/lib/utils/cn.ts",
|
||||
"src/middleware.ts"
|
||||
],
|
||||
"reporter": [
|
||||
"text",
|
||||
"text-summary",
|
||||
"html",
|
||||
"json",
|
||||
"lcov"
|
||||
],
|
||||
"reporter": ["text", "text-summary", "html", "json", "lcov"],
|
||||
"report-dir": "./coverage-combined",
|
||||
"temp-dir": "./.nyc_output",
|
||||
"sourceMap": true,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -110,6 +110,7 @@ src/lib/api/generated/
|
||||
### 2.3 When to Regenerate
|
||||
|
||||
Regenerate the API client when:
|
||||
|
||||
- Backend API changes (new endpoints, updated models)
|
||||
- After pulling backend changes from git
|
||||
- When types don't match backend responses
|
||||
@@ -122,6 +123,7 @@ Regenerate the API client when:
|
||||
### 3.1 Using Generated Services
|
||||
|
||||
**Example: Fetching users**
|
||||
|
||||
```typescript
|
||||
import { UsersService } from '@/lib/api/generated';
|
||||
|
||||
@@ -129,19 +131,20 @@ async function getUsers() {
|
||||
const users = await UsersService.getUsers({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
search: 'john'
|
||||
search: 'john',
|
||||
});
|
||||
return users;
|
||||
}
|
||||
```
|
||||
|
||||
**Example: Creating a user**
|
||||
|
||||
```typescript
|
||||
import { AdminService } from '@/lib/api/generated';
|
||||
|
||||
async function createUser(data: CreateUserDto) {
|
||||
const newUser = await AdminService.createUser({
|
||||
requestBody: data
|
||||
requestBody: data,
|
||||
});
|
||||
return newUser;
|
||||
}
|
||||
@@ -156,19 +159,19 @@ import { apiClient } from '@/lib/api/client';
|
||||
|
||||
// GET request
|
||||
const response = await apiClient.get<User[]>('/users', {
|
||||
params: { page: 1, search: 'john' }
|
||||
params: { page: 1, search: 'john' },
|
||||
});
|
||||
|
||||
// POST request
|
||||
const response = await apiClient.post<User>('/admin/users', {
|
||||
email: 'user@example.com',
|
||||
first_name: 'John',
|
||||
password: 'secure123'
|
||||
password: 'secure123',
|
||||
});
|
||||
|
||||
// PATCH request
|
||||
const response = await apiClient.patch<User>(`/users/${userId}`, {
|
||||
first_name: 'Jane'
|
||||
first_name: 'Jane',
|
||||
});
|
||||
|
||||
// DELETE request
|
||||
@@ -178,27 +181,30 @@ await apiClient.delete(`/users/${userId}`);
|
||||
### 3.3 Request Configuration
|
||||
|
||||
**Timeouts:**
|
||||
|
||||
```typescript
|
||||
const response = await apiClient.get('/users', {
|
||||
timeout: 5000 // 5 seconds
|
||||
timeout: 5000, // 5 seconds
|
||||
});
|
||||
```
|
||||
|
||||
**Custom Headers:**
|
||||
|
||||
```typescript
|
||||
const response = await apiClient.post('/users', data, {
|
||||
headers: {
|
||||
'X-Custom-Header': 'value'
|
||||
}
|
||||
'X-Custom-Header': 'value',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Request Cancellation:**
|
||||
|
||||
```typescript
|
||||
const controller = new AbortController();
|
||||
|
||||
const response = await apiClient.get('/users', {
|
||||
signal: controller.signal
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// Cancel the request
|
||||
@@ -215,15 +221,13 @@ The Axios client automatically adds the Authorization header to all requests:
|
||||
|
||||
```typescript
|
||||
// src/lib/api/client.ts
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
);
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
You don't need to manually add auth headers - they're added automatically!
|
||||
@@ -246,7 +250,7 @@ apiClient.interceptors.response.use(
|
||||
// Refresh tokens
|
||||
const refreshToken = getRefreshToken();
|
||||
const { access_token, refresh_token } = await AuthService.refreshToken({
|
||||
requestBody: { refresh_token: refreshToken }
|
||||
requestBody: { refresh_token: refreshToken },
|
||||
});
|
||||
|
||||
// Update stored tokens
|
||||
@@ -255,7 +259,6 @@ apiClient.interceptors.response.use(
|
||||
// Retry original request with new token
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
return apiClient.request(originalRequest);
|
||||
|
||||
} catch (refreshError) {
|
||||
// Refresh failed - logout user
|
||||
useAuthStore.getState().clearAuth();
|
||||
@@ -278,14 +281,11 @@ import { useAuthStore } from '@/stores/authStore';
|
||||
async function login(email: string, password: string) {
|
||||
try {
|
||||
const response = await AuthService.login({
|
||||
requestBody: { email, password }
|
||||
requestBody: { email, password },
|
||||
});
|
||||
|
||||
// Store tokens and user
|
||||
useAuthStore.getState().setTokens(
|
||||
response.access_token,
|
||||
response.refresh_token
|
||||
);
|
||||
useAuthStore.getState().setTokens(response.access_token, response.refresh_token);
|
||||
useAuthStore.getState().setUser(response.user);
|
||||
|
||||
return response.user;
|
||||
@@ -319,6 +319,7 @@ The backend returns structured errors:
|
||||
### 5.2 Parsing Errors
|
||||
|
||||
**Error Parser** (`src/lib/api/errors.ts`):
|
||||
|
||||
```typescript
|
||||
import type { AxiosError } from 'axios';
|
||||
|
||||
@@ -341,80 +342,93 @@ export function parseAPIError(error: AxiosError<APIErrorResponse>): APIError[] {
|
||||
|
||||
// Network errors
|
||||
if (!error.response) {
|
||||
return [{
|
||||
code: 'NETWORK_ERROR',
|
||||
message: 'Network error. Please check your connection.',
|
||||
}];
|
||||
return [
|
||||
{
|
||||
code: 'NETWORK_ERROR',
|
||||
message: 'Network error. Please check your connection.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// HTTP status errors
|
||||
const status = error.response.status;
|
||||
if (status === 403) {
|
||||
return [{
|
||||
code: 'FORBIDDEN',
|
||||
message: "You don't have permission to perform this action.",
|
||||
}];
|
||||
return [
|
||||
{
|
||||
code: 'FORBIDDEN',
|
||||
message: "You don't have permission to perform this action.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
return [{
|
||||
code: 'NOT_FOUND',
|
||||
message: 'The requested resource was not found.',
|
||||
}];
|
||||
return [
|
||||
{
|
||||
code: 'NOT_FOUND',
|
||||
message: 'The requested resource was not found.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
return [{
|
||||
code: 'RATE_LIMIT',
|
||||
message: 'Too many requests. Please slow down.',
|
||||
}];
|
||||
return [
|
||||
{
|
||||
code: 'RATE_LIMIT',
|
||||
message: 'Too many requests. Please slow down.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
return [{
|
||||
code: 'SERVER_ERROR',
|
||||
message: 'A server error occurred. Please try again later.',
|
||||
}];
|
||||
return [
|
||||
{
|
||||
code: 'SERVER_ERROR',
|
||||
message: 'A server error occurred. Please try again later.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return [{
|
||||
code: 'UNKNOWN',
|
||||
message: error.message || 'An unexpected error occurred.',
|
||||
}];
|
||||
return [
|
||||
{
|
||||
code: 'UNKNOWN',
|
||||
message: error.message || 'An unexpected error occurred.',
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Error Code Mapping
|
||||
|
||||
**Error Messages** (`src/lib/api/errorMessages.ts`):
|
||||
|
||||
```typescript
|
||||
export const ERROR_MESSAGES: Record<string, string> = {
|
||||
// Authentication errors (AUTH_xxx)
|
||||
'AUTH_001': 'Invalid email or password',
|
||||
'AUTH_002': 'Account is inactive',
|
||||
'AUTH_003': 'Invalid or expired token',
|
||||
AUTH_001: 'Invalid email or password',
|
||||
AUTH_002: 'Account is inactive',
|
||||
AUTH_003: 'Invalid or expired token',
|
||||
|
||||
// User errors (USER_xxx)
|
||||
'USER_001': 'User not found',
|
||||
'USER_002': 'This email is already registered',
|
||||
'USER_003': 'Invalid user data',
|
||||
USER_001: 'User not found',
|
||||
USER_002: 'This email is already registered',
|
||||
USER_003: 'Invalid user data',
|
||||
|
||||
// Validation errors (VAL_xxx)
|
||||
'VAL_001': 'Invalid input. Please check your data.',
|
||||
'VAL_002': 'Email format is invalid',
|
||||
'VAL_003': 'Password does not meet requirements',
|
||||
VAL_001: 'Invalid input. Please check your data.',
|
||||
VAL_002: 'Email format is invalid',
|
||||
VAL_003: 'Password does not meet requirements',
|
||||
|
||||
// Organization errors (ORG_xxx)
|
||||
'ORG_001': 'Organization name already exists',
|
||||
'ORG_002': 'Organization not found',
|
||||
ORG_001: 'Organization name already exists',
|
||||
ORG_002: 'Organization not found',
|
||||
|
||||
// Permission errors (PERM_xxx)
|
||||
'PERM_001': 'Insufficient permissions',
|
||||
'PERM_002': 'Admin access required',
|
||||
PERM_001: 'Insufficient permissions',
|
||||
PERM_002: 'Admin access required',
|
||||
|
||||
// Rate limiting (RATE_xxx)
|
||||
'RATE_001': 'Too many requests. Please try again later.',
|
||||
RATE_001: 'Too many requests. Please try again later.',
|
||||
};
|
||||
|
||||
export function getErrorMessage(code: string): string {
|
||||
@@ -425,6 +439,7 @@ export function getErrorMessage(code: string): string {
|
||||
### 5.4 Displaying Errors
|
||||
|
||||
**In React Query:**
|
||||
|
||||
```typescript
|
||||
import { toast } from 'sonner';
|
||||
import { parseAPIError, getErrorMessage } from '@/lib/api/errors';
|
||||
@@ -442,6 +457,7 @@ export function useUpdateUser() {
|
||||
```
|
||||
|
||||
**In Forms:**
|
||||
|
||||
```typescript
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
@@ -459,9 +475,9 @@ const onSubmit = async (data: FormData) => {
|
||||
});
|
||||
|
||||
// Set general error
|
||||
if (errors.some(err => !err.field)) {
|
||||
if (errors.some((err) => !err.field)) {
|
||||
form.setError('root', {
|
||||
message: errors.find(err => !err.field)?.message || 'An error occurred',
|
||||
message: errors.find((err) => !err.field)?.message || 'An error occurred',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -505,8 +521,7 @@ export function useCreateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateUserDto) =>
|
||||
AdminService.createUser({ requestBody: data }),
|
||||
mutationFn: (data: CreateUserDto) => AdminService.createUser({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
toast.success('User created successfully');
|
||||
@@ -542,8 +557,7 @@ export function useDeleteUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) =>
|
||||
AdminService.deleteUser({ userId }),
|
||||
mutationFn: (userId: string) => AdminService.deleteUser({ userId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
toast.success('User deleted successfully');
|
||||
@@ -614,7 +628,7 @@ export function useToggleUserActive() {
|
||||
mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) =>
|
||||
AdminService.updateUser({
|
||||
userId,
|
||||
requestBody: { is_active: isActive }
|
||||
requestBody: { is_active: isActive },
|
||||
}),
|
||||
onMutate: async ({ userId, isActive }) => {
|
||||
// Cancel outgoing refetches
|
||||
@@ -687,9 +701,7 @@ export const handlers = [
|
||||
}),
|
||||
|
||||
rest.delete('/api/v1/admin/users/:userId', (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.json({ success: true, message: 'User deleted' })
|
||||
);
|
||||
return res(ctx.json({ success: true, message: 'User deleted' }));
|
||||
}),
|
||||
];
|
||||
```
|
||||
@@ -844,6 +856,7 @@ function UserDetail({ userId }: { userId: string }) {
|
||||
**Symptom**: `Access-Control-Allow-Origin` error in console
|
||||
|
||||
**Solution**: Ensure backend CORS is configured for frontend URL:
|
||||
|
||||
```python
|
||||
# backend/app/main.py
|
||||
BACKEND_CORS_ORIGINS = ["http://localhost:3000"]
|
||||
@@ -854,12 +867,14 @@ BACKEND_CORS_ORIGINS = ["http://localhost:3000"]
|
||||
**Symptom**: All API calls return 401
|
||||
|
||||
**Possible Causes**:
|
||||
|
||||
1. No token in store: Check `useAuthStore.getState().accessToken`
|
||||
2. Token expired: Check token expiration
|
||||
3. Token invalid: Try logging in again
|
||||
4. Interceptor not working: Check interceptor configuration
|
||||
|
||||
**Debug**:
|
||||
|
||||
```typescript
|
||||
// Log token in interceptor
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
@@ -877,6 +892,7 @@ apiClient.interceptors.request.use((config) => {
|
||||
**Symptom**: TypeScript errors about response types
|
||||
|
||||
**Solution**: Regenerate API client to sync with backend
|
||||
|
||||
```bash
|
||||
npm run generate:api
|
||||
```
|
||||
@@ -886,10 +902,11 @@ npm run generate:api
|
||||
**Symptom**: UI shows old data after mutation
|
||||
|
||||
**Solution**: Invalidate queries after mutations
|
||||
|
||||
```typescript
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 9.5 Network Timeout
|
||||
@@ -897,6 +914,7 @@ onSuccess: () => {
|
||||
**Symptom**: Requests timeout
|
||||
|
||||
**Solution**: Increase timeout or check backend performance
|
||||
|
||||
```typescript
|
||||
const apiClient = axios.create({
|
||||
timeout: 60000, // 60 seconds
|
||||
@@ -908,6 +926,7 @@ const apiClient = axios.create({
|
||||
## Conclusion
|
||||
|
||||
This guide covers the essential patterns for integrating with the FastAPI backend. For more advanced use cases, refer to:
|
||||
|
||||
- [TanStack Query Documentation](https://tanstack.com/query/latest)
|
||||
- [Axios Documentation](https://axios-http.com/)
|
||||
- Backend API documentation at `/docs` endpoint
|
||||
|
||||
@@ -88,6 +88,7 @@ This frontend template provides a production-ready foundation for building moder
|
||||
### 2.1 Core Framework
|
||||
|
||||
**Next.js 15.x (App Router)**
|
||||
|
||||
- **Why**: Modern React framework with RSC, excellent DX, optimized performance
|
||||
- **App Router**: Preferred over Pages Router for better data fetching, layouts, and streaming
|
||||
- **Server Components**: Default for better performance, client components for interactivity
|
||||
@@ -96,11 +97,13 @@ This frontend template provides a production-ready foundation for building moder
|
||||
### 2.2 State Management
|
||||
|
||||
**TanStack Query (React Query v5)**
|
||||
|
||||
- **Purpose**: Server state management (all API data)
|
||||
- **Why**: Automatic caching, background refetching, request deduplication, optimistic updates
|
||||
- **Usage**: All data fetching goes through React Query hooks
|
||||
|
||||
**Zustand 4.x**
|
||||
|
||||
- **Purpose**: Client-only state (authentication, UI preferences)
|
||||
- **Why**: Minimal boilerplate, no Context API overhead, simple API
|
||||
- **Usage**: Auth store, UI store (sidebar, theme, modals)
|
||||
@@ -109,27 +112,32 @@ This frontend template provides a production-ready foundation for building moder
|
||||
### 2.3 UI Layer
|
||||
|
||||
**shadcn/ui**
|
||||
|
||||
- **Why**: Accessible components (Radix UI), customizable, copy-paste (not npm dependency)
|
||||
- **Components**: Button, Card, Dialog, Form, Input, Table, Toast, etc.
|
||||
- **Customization**: Tailwind-based, easy to adapt to design system
|
||||
|
||||
**Tailwind CSS 4.x**
|
||||
|
||||
- **Why**: Utility-first, excellent DX, small bundle size, dark mode support
|
||||
- **Strategy**: Class-based dark mode, mobile-first responsive design
|
||||
- **Customization**: Custom theme colors, design tokens
|
||||
|
||||
**Recharts 2.x**
|
||||
|
||||
- **Purpose**: Charts for admin dashboard
|
||||
- **Why**: React-native, composable, responsive, themed with Tailwind colors
|
||||
|
||||
### 2.4 API Layer
|
||||
|
||||
**@hey-api/openapi-ts**
|
||||
|
||||
- **Purpose**: Generate TypeScript client from backend OpenAPI spec
|
||||
- **Why**: Type-safe API calls, auto-generated types matching backend
|
||||
- **Alternative**: Considered `openapi-typescript-codegen` but this is more actively maintained
|
||||
|
||||
**Axios 1.x**
|
||||
|
||||
- **Purpose**: HTTP client for API calls
|
||||
- **Why**: Interceptor support for auth, better error handling than fetch
|
||||
- **Usage**: Wrapped in generated API client, configured with auth interceptors
|
||||
@@ -137,10 +145,12 @@ This frontend template provides a production-ready foundation for building moder
|
||||
### 2.5 Forms & Validation
|
||||
|
||||
**react-hook-form 7.x**
|
||||
|
||||
- **Purpose**: Form state management
|
||||
- **Why**: Excellent performance, minimal re-renders, great DX
|
||||
|
||||
**Zod 3.x**
|
||||
|
||||
- **Purpose**: Runtime type validation and schema definition
|
||||
- **Why**: Type inference, composable schemas, integrates with react-hook-form
|
||||
- **Usage**: All forms use Zod schemas with `zodResolver`
|
||||
@@ -148,10 +158,12 @@ This frontend template provides a production-ready foundation for building moder
|
||||
### 2.6 Testing
|
||||
|
||||
**Jest + React Testing Library**
|
||||
|
||||
- **Purpose**: Unit and component tests
|
||||
- **Why**: Industry standard, excellent React support, accessibility-focused
|
||||
|
||||
**Playwright**
|
||||
|
||||
- **Purpose**: End-to-end testing
|
||||
- **Why**: Fast, reliable, multi-browser, great debugging tools
|
||||
- **Coverage Target**: 90%+ for template robustness
|
||||
@@ -208,6 +220,7 @@ Inspired by backend's 5-layer architecture, frontend follows similar separation
|
||||
```
|
||||
|
||||
**Key Rules:**
|
||||
|
||||
- Pages/Layouts should NOT contain business logic
|
||||
- Components should NOT call API client directly (use hooks)
|
||||
- Hooks should NOT contain display logic
|
||||
@@ -217,6 +230,7 @@ Inspired by backend's 5-layer architecture, frontend follows similar separation
|
||||
### 3.2 Component Patterns
|
||||
|
||||
**Server Components by Default:**
|
||||
|
||||
```typescript
|
||||
// app/(authenticated)/admin/users/page.tsx
|
||||
// Server Component - can fetch data directly
|
||||
@@ -232,6 +246,7 @@ export default async function UsersPage() {
|
||||
```
|
||||
|
||||
**Client Components for Interactivity:**
|
||||
|
||||
```typescript
|
||||
// components/admin/UserTable.tsx
|
||||
'use client';
|
||||
@@ -245,6 +260,7 @@ export function UserTable() {
|
||||
```
|
||||
|
||||
**Composition Over Prop Drilling:**
|
||||
|
||||
```typescript
|
||||
// Good: Use composition
|
||||
<Card>
|
||||
@@ -358,6 +374,7 @@ Each module has one clear responsibility:
|
||||
```
|
||||
|
||||
**Token Refresh Flow (Automatic):**
|
||||
|
||||
```
|
||||
API Request → 401 Response → Check if refresh token exists
|
||||
↓ Yes ↓ No
|
||||
@@ -369,11 +386,13 @@ New Tokens → Update Store → Retry Original Request
|
||||
### 4.3 State Updates
|
||||
|
||||
**Server State (React Query):**
|
||||
|
||||
- Automatic background refetch
|
||||
- Cache invalidation on mutations
|
||||
- Optimistic updates where appropriate
|
||||
|
||||
**Client State (Zustand):**
|
||||
|
||||
- Direct store updates
|
||||
- No actions/reducers boilerplate
|
||||
- Subscriptions for components
|
||||
@@ -385,6 +404,7 @@ New Tokens → Update Store → Retry Original Request
|
||||
### 5.1 Philosophy
|
||||
|
||||
**Use the Right Tool for the Right Job:**
|
||||
|
||||
- Server data → TanStack Query
|
||||
- Auth & tokens → Zustand
|
||||
- UI state → Zustand (minimal)
|
||||
@@ -392,6 +412,7 @@ New Tokens → Update Store → Retry Original Request
|
||||
- Component state → useState/useReducer
|
||||
|
||||
**Avoid Redundancy:**
|
||||
|
||||
- DON'T duplicate server data in Zustand
|
||||
- DON'T store API responses in global state
|
||||
- DO keep state as local as possible
|
||||
@@ -399,34 +420,36 @@ New Tokens → Update Store → Retry Original Request
|
||||
### 5.2 TanStack Query Configuration
|
||||
|
||||
**Global Config** (`src/config/queryClient.ts`):
|
||||
|
||||
```typescript
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60000, // 1 minute
|
||||
cacheTime: 300000, // 5 minutes
|
||||
retry: 3, // Retry failed requests
|
||||
refetchOnWindowFocus: true, // Refetch on tab focus
|
||||
refetchOnReconnect: true, // Refetch on network reconnect
|
||||
staleTime: 60000, // 1 minute
|
||||
cacheTime: 300000, // 5 minutes
|
||||
retry: 3, // Retry failed requests
|
||||
refetchOnWindowFocus: true, // Refetch on tab focus
|
||||
refetchOnReconnect: true, // Refetch on network reconnect
|
||||
},
|
||||
mutations: {
|
||||
retry: 1, // Retry mutations once
|
||||
retry: 1, // Retry mutations once
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Query Key Structure:**
|
||||
|
||||
```typescript
|
||||
['users'] // List all users
|
||||
['users', userId] // Single user
|
||||
['users', { page: 1, search: 'john' }] // Filtered list
|
||||
['organizations', orgId, 'members'] // Nested resource
|
||||
['users'][('users', userId)][('users', { page: 1, search: 'john' })][ // List all users // Single user // Filtered list
|
||||
('organizations', orgId, 'members')
|
||||
]; // Nested resource
|
||||
```
|
||||
|
||||
### 5.3 Zustand Stores
|
||||
|
||||
**Auth Store** (`src/stores/authStore.ts`):
|
||||
|
||||
```typescript
|
||||
interface AuthStore {
|
||||
user: User | null;
|
||||
@@ -443,6 +466,7 @@ interface AuthStore {
|
||||
```
|
||||
|
||||
**UI Store** (`src/stores/uiStore.ts`):
|
||||
|
||||
```typescript
|
||||
interface UIStore {
|
||||
sidebarOpen: boolean;
|
||||
@@ -454,6 +478,7 @@ interface UIStore {
|
||||
```
|
||||
|
||||
**Store Guidelines:**
|
||||
|
||||
- Keep stores small and focused
|
||||
- Use selectors for computed values
|
||||
- Persist to localStorage where appropriate
|
||||
@@ -480,6 +505,7 @@ Component → useAuth() hook → AuthContext → Zustand Store → Storage Layer
|
||||
**Why This Pattern?**
|
||||
|
||||
✅ **Benefits:**
|
||||
|
||||
- **Testable**: E2E tests can inject mock stores without backend
|
||||
- **Performant**: Zustand handles state efficiently, Context is just a thin wrapper
|
||||
- **Type-safe**: Full TypeScript inference throughout
|
||||
@@ -488,6 +514,7 @@ Component → useAuth() hook → AuthContext → Zustand Store → Storage Layer
|
||||
- **React-idiomatic**: Follows React best practices
|
||||
|
||||
**Key Design Principles:**
|
||||
|
||||
1. **Thin Context Layer**: Context only provides dependency injection, no business logic
|
||||
2. **Zustand for State**: All state management stays in Zustand (no duplicated state)
|
||||
3. **Backward Compatible**: Internal refactor only, no API changes
|
||||
@@ -516,6 +543,7 @@ window.__TEST_AUTH_STORE__ = mockStoreHook;
|
||||
```
|
||||
|
||||
**Implementation Details:**
|
||||
|
||||
- Stores Zustand hook function (not state) in Context
|
||||
- Priority: explicit prop → E2E test store → production singleton
|
||||
- Type-safe window global extension for E2E injection
|
||||
@@ -530,23 +558,25 @@ window.__TEST_AUTH_STORE__ = mockStoreHook;
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
|
||||
// Pattern 2: Selector (optimized for performance)
|
||||
const user = useAuth(state => state.user);
|
||||
const user = useAuth((state) => state.user);
|
||||
```
|
||||
|
||||
**Why Polymorphic?**
|
||||
|
||||
- Simple pattern for most use cases
|
||||
- Optimized pattern available when needed
|
||||
- Type-safe with function overloads
|
||||
- No performance overhead
|
||||
|
||||
**Critical Implementation Detail:**
|
||||
|
||||
```typescript
|
||||
export function useAuth(): AuthState;
|
||||
export function useAuth<T>(selector: (state: AuthState) => T): T;
|
||||
export function useAuth<T>(selector?: (state: AuthState) => T): AuthState | T {
|
||||
const storeHook = useContext(AuthContext);
|
||||
if (!storeHook) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
// CRITICAL: Call the hook internally (follows React Rules of Hooks)
|
||||
return selector ? storeHook(selector) : storeHook();
|
||||
@@ -580,6 +610,7 @@ function MyComponent() {
|
||||
```
|
||||
|
||||
**Why?**
|
||||
|
||||
- Component re-renders when auth state changes
|
||||
- Type-safe access to all state properties
|
||||
- Clean, idiomatic React code
|
||||
@@ -605,6 +636,7 @@ export function useLogin() {
|
||||
```
|
||||
|
||||
**Why?**
|
||||
|
||||
- Event handlers run outside React render cycle
|
||||
- Don't need to re-render when state changes
|
||||
- Using `getState()` directly is cleaner
|
||||
@@ -694,6 +726,7 @@ test.describe('Protected Pages', () => {
|
||||
```
|
||||
|
||||
**Why This Order?**
|
||||
|
||||
- AuthProvider must wrap AuthInitializer (AuthInitializer uses auth state)
|
||||
- AuthProvider should wrap all app providers (auth available everywhere)
|
||||
- Keep provider tree shallow for performance
|
||||
@@ -701,15 +734,18 @@ test.describe('Protected Pages', () => {
|
||||
### 6.6 Token Management Strategy
|
||||
|
||||
**Two-Token System:**
|
||||
|
||||
- **Access Token**: Short-lived (15 min), stored in memory/sessionStorage
|
||||
- **Refresh Token**: Long-lived (7 days), stored in httpOnly cookie (preferred) or localStorage
|
||||
|
||||
**Token Storage Decision:**
|
||||
|
||||
- **Primary**: httpOnly cookies (most secure, prevents XSS)
|
||||
- **Fallback**: localStorage with encryption wrapper (if cookies not feasible)
|
||||
- **Access Token**: sessionStorage or React state (short-lived, acceptable risk)
|
||||
|
||||
**Token Rotation:**
|
||||
|
||||
- On refresh, both tokens are rotated
|
||||
- Old refresh token is invalidated immediately
|
||||
- Prevents token replay attacks
|
||||
@@ -717,6 +753,7 @@ test.describe('Protected Pages', () => {
|
||||
### 6.2 Per-Device Session Tracking
|
||||
|
||||
Backend tracks sessions per device:
|
||||
|
||||
- Each login creates a unique session with device info
|
||||
- Users can view all active sessions
|
||||
- Users can revoke individual sessions
|
||||
@@ -724,6 +761,7 @@ Backend tracks sessions per device:
|
||||
- "Logout All" deactivates all sessions
|
||||
|
||||
Frontend Implementation:
|
||||
|
||||
- Session list page at `/settings/sessions`
|
||||
- Display device name, IP, location, last used
|
||||
- Highlight current session
|
||||
@@ -732,6 +770,7 @@ Frontend Implementation:
|
||||
### 6.3 Auth Guard Implementation
|
||||
|
||||
**Layout-Based Protection:**
|
||||
|
||||
```typescript
|
||||
// app/(authenticated)/layout.tsx
|
||||
export default function AuthenticatedLayout({ children }) {
|
||||
@@ -746,6 +785,7 @@ export default function AuthenticatedLayout({ children }) {
|
||||
```
|
||||
|
||||
**Permission Checks:**
|
||||
|
||||
```typescript
|
||||
// app/(authenticated)/admin/layout.tsx
|
||||
export default function AdminLayout({ children }) {
|
||||
@@ -776,12 +816,14 @@ export default function AdminLayout({ children }) {
|
||||
### 7.1 OpenAPI Client Generation
|
||||
|
||||
**Workflow:**
|
||||
|
||||
```
|
||||
Backend OpenAPI Spec → @hey-api/openapi-ts → TypeScript Client
|
||||
(/api/v1/openapi.json) (src/lib/api/generated/)
|
||||
```
|
||||
|
||||
**Generation Script** (`scripts/generate-api-client.sh`):
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
API_URL="${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8000}"
|
||||
@@ -792,6 +834,7 @@ npx @hey-api/openapi-ts \
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Type-safe API calls
|
||||
- Auto-completion in IDE
|
||||
- Compile-time error checking
|
||||
@@ -801,6 +844,7 @@ npx @hey-api/openapi-ts \
|
||||
### 7.2 Axios Configuration
|
||||
|
||||
**Base Instance** (`src/lib/api/client.ts`):
|
||||
|
||||
```typescript
|
||||
export const apiClient = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
||||
@@ -812,6 +856,7 @@ export const apiClient = axios.create({
|
||||
```
|
||||
|
||||
**Request Interceptor:**
|
||||
|
||||
```typescript
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
@@ -826,6 +871,7 @@ apiClient.interceptors.request.use(
|
||||
```
|
||||
|
||||
**Response Interceptor:**
|
||||
|
||||
```typescript
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
@@ -850,6 +896,7 @@ apiClient.interceptors.response.use(
|
||||
### 7.3 Error Handling
|
||||
|
||||
**Backend Error Format:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: false,
|
||||
@@ -864,24 +911,28 @@ apiClient.interceptors.response.use(
|
||||
```
|
||||
|
||||
**Frontend Error Parsing:**
|
||||
|
||||
```typescript
|
||||
export function parseAPIError(error: AxiosError): APIError {
|
||||
if (error.response?.data?.errors) {
|
||||
return error.response.data.errors;
|
||||
}
|
||||
return [{
|
||||
code: 'UNKNOWN',
|
||||
message: 'An unexpected error occurred'
|
||||
}];
|
||||
return [
|
||||
{
|
||||
code: 'UNKNOWN',
|
||||
message: 'An unexpected error occurred',
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**Error Code Mapping:**
|
||||
|
||||
```typescript
|
||||
const ERROR_MESSAGES = {
|
||||
'AUTH_001': 'Invalid email or password',
|
||||
'USER_002': 'This email is already registered',
|
||||
'VAL_001': 'Please check your input',
|
||||
AUTH_001: 'Invalid email or password',
|
||||
USER_002: 'This email is already registered',
|
||||
VAL_001: 'Please check your input',
|
||||
// ... all backend error codes
|
||||
};
|
||||
```
|
||||
@@ -889,6 +940,7 @@ const ERROR_MESSAGES = {
|
||||
### 7.4 React Query Hooks Pattern
|
||||
|
||||
**Standard Pattern:**
|
||||
|
||||
```typescript
|
||||
// lib/api/hooks/useUsers.ts
|
||||
export function useUsers(filters?: UserFilters) {
|
||||
@@ -955,6 +1007,7 @@ app/
|
||||
```
|
||||
|
||||
**Route Groups** (parentheses in folder name):
|
||||
|
||||
- Organize routes without affecting URL
|
||||
- Apply different layouts to route subsets
|
||||
- Example: `(auth)` and `(authenticated)` have different layouts
|
||||
@@ -962,23 +1015,27 @@ app/
|
||||
### 8.2 Layout Strategy
|
||||
|
||||
**Root Layout** (`app/layout.tsx`):
|
||||
|
||||
- HTML structure
|
||||
- React Query provider
|
||||
- Theme provider
|
||||
- Global metadata
|
||||
|
||||
**Auth Layout** (`app/(auth)/layout.tsx`):
|
||||
|
||||
- Centered form container
|
||||
- No header/footer
|
||||
- Minimal styling
|
||||
|
||||
**Authenticated Layout** (`app/(authenticated)/layout.tsx`):
|
||||
|
||||
- Auth guard (redirect if not authenticated)
|
||||
- Header with user menu
|
||||
- Main content area
|
||||
- Footer
|
||||
|
||||
**Admin Layout** (`app/(authenticated)/admin/layout.tsx`):
|
||||
|
||||
- Admin sidebar
|
||||
- Breadcrumbs
|
||||
- Admin permission check (is_superuser)
|
||||
@@ -1036,11 +1093,13 @@ components/
|
||||
### 9.2 Component Guidelines
|
||||
|
||||
**Naming:**
|
||||
|
||||
- PascalCase for components: `UserTable.tsx`
|
||||
- Match file name with component name
|
||||
- One component per file
|
||||
|
||||
**Structure:**
|
||||
|
||||
```typescript
|
||||
// 1. Imports
|
||||
import { useState } from 'react';
|
||||
@@ -1078,6 +1137,7 @@ export function UserTable({ filters }: UserTableProps) {
|
||||
```
|
||||
|
||||
**Best Practices:**
|
||||
|
||||
- Prefer named exports over default exports
|
||||
- Destructure props in function signature
|
||||
- Extract complex logic to hooks
|
||||
@@ -1087,6 +1147,7 @@ export function UserTable({ filters }: UserTableProps) {
|
||||
### 9.3 Styling Strategy
|
||||
|
||||
**Tailwind Utility Classes:**
|
||||
|
||||
```typescript
|
||||
<button className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90">
|
||||
Click Me
|
||||
@@ -1094,6 +1155,7 @@ export function UserTable({ filters }: UserTableProps) {
|
||||
```
|
||||
|
||||
**Conditional Classes with cn():**
|
||||
|
||||
```typescript
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
@@ -1105,6 +1167,7 @@ import { cn } from '@/lib/utils/cn';
|
||||
```
|
||||
|
||||
**Dark Mode:**
|
||||
|
||||
```typescript
|
||||
<div className="bg-white dark:bg-gray-900 text-black dark:text-white">
|
||||
Content
|
||||
@@ -1134,18 +1197,21 @@ import { cn } from '@/lib/utils/cn';
|
||||
### 10.2 Test Categories
|
||||
|
||||
**Unit Tests** (60% of suite):
|
||||
|
||||
- Utilities (`lib/utils/`)
|
||||
- Custom hooks (`hooks/`)
|
||||
- Services (`services/`)
|
||||
- Pure functions
|
||||
|
||||
**Component Tests** (30% of suite):
|
||||
|
||||
- Reusable components (`components/`)
|
||||
- Forms with validation
|
||||
- User interactions
|
||||
- Accessibility
|
||||
|
||||
**Integration Tests** (E2E with Playwright, 10% of suite):
|
||||
|
||||
- Critical user flows:
|
||||
- Login → Dashboard
|
||||
- Admin: Create/Edit/Delete User
|
||||
@@ -1157,6 +1223,7 @@ import { cn } from '@/lib/utils/cn';
|
||||
### 10.3 Testing Tools
|
||||
|
||||
**Jest + React Testing Library:**
|
||||
|
||||
```typescript
|
||||
// UserTable.test.tsx
|
||||
import { render, screen } from '@testing-library/react';
|
||||
@@ -1169,6 +1236,7 @@ test('renders user table with data', async () => {
|
||||
```
|
||||
|
||||
**Playwright E2E:**
|
||||
|
||||
```typescript
|
||||
// tests/e2e/auth.spec.ts
|
||||
test('user can login', async ({ page }) => {
|
||||
@@ -1183,11 +1251,13 @@ test('user can login', async ({ page }) => {
|
||||
### 10.4 Coverage Target
|
||||
|
||||
**Goal: 90%+ Overall Coverage**
|
||||
|
||||
- Unit tests: 95%+
|
||||
- Component tests: 85%+
|
||||
- Integration tests: Critical paths only
|
||||
|
||||
**Justification for 90%:**
|
||||
|
||||
- This is a template for production projects
|
||||
- High coverage ensures robustness
|
||||
- Confidence for extension and customization
|
||||
@@ -1199,6 +1269,7 @@ test('user can login', async ({ page }) => {
|
||||
### 11.1 Optimization Strategies
|
||||
|
||||
**Code Splitting:**
|
||||
|
||||
```typescript
|
||||
// Dynamic imports for heavy components
|
||||
const AdminDashboard = dynamic(() => import('./AdminDashboard'), {
|
||||
@@ -1207,6 +1278,7 @@ const AdminDashboard = dynamic(() => import('./AdminDashboard'), {
|
||||
```
|
||||
|
||||
**Image Optimization:**
|
||||
|
||||
```typescript
|
||||
import Image from 'next/image';
|
||||
|
||||
@@ -1220,11 +1292,13 @@ import Image from 'next/image';
|
||||
```
|
||||
|
||||
**React Query Caching:**
|
||||
|
||||
- Stale time: 1 minute (reduce unnecessary refetches)
|
||||
- Cache time: 5 minutes (keep data in memory)
|
||||
- Background refetch: Yes (keep data fresh)
|
||||
|
||||
**Bundle Size Monitoring:**
|
||||
|
||||
```bash
|
||||
npm run build && npm run analyze
|
||||
# Use webpack-bundle-analyzer to identify large dependencies
|
||||
@@ -1233,12 +1307,14 @@ npm run build && npm run analyze
|
||||
### 11.2 Performance Targets
|
||||
|
||||
**Lighthouse Scores:**
|
||||
|
||||
- Performance: >90
|
||||
- Accessibility: 100
|
||||
- Best Practices: >90
|
||||
- SEO: >90
|
||||
|
||||
**Core Web Vitals:**
|
||||
|
||||
- LCP (Largest Contentful Paint): <2.5s
|
||||
- FID (First Input Delay): <100ms
|
||||
- CLS (Cumulative Layout Shift): <0.1
|
||||
@@ -1250,16 +1326,19 @@ npm run build && npm run analyze
|
||||
### 12.1 Client-Side Security
|
||||
|
||||
**XSS Prevention:**
|
||||
|
||||
- React's default escaping (JSX)
|
||||
- Sanitize user input if rendering HTML
|
||||
- CSP headers (configured in backend)
|
||||
|
||||
**Token Security:**
|
||||
|
||||
- Access token: sessionStorage or memory (15 min expiry mitigates risk)
|
||||
- Refresh token: httpOnly cookie (preferred) or encrypted localStorage
|
||||
- Never log tokens to console in production
|
||||
|
||||
**HTTPS Only:**
|
||||
|
||||
- All production requests over HTTPS
|
||||
- Cookies with Secure flag
|
||||
- No mixed content
|
||||
@@ -1267,11 +1346,13 @@ npm run build && npm run analyze
|
||||
### 12.2 Input Validation
|
||||
|
||||
**Client-Side Validation:**
|
||||
|
||||
- Zod schemas for all forms
|
||||
- Immediate feedback to users
|
||||
- Prevent malformed requests
|
||||
|
||||
**Remember:**
|
||||
|
||||
- Client validation is for UX
|
||||
- Backend validation is for security
|
||||
- Always trust backend, not client
|
||||
@@ -1279,12 +1360,14 @@ npm run build && npm run analyze
|
||||
### 12.3 Dependency Security
|
||||
|
||||
**Regular Audits:**
|
||||
|
||||
```bash
|
||||
npm audit
|
||||
npm audit fix
|
||||
```
|
||||
|
||||
**Automated Scanning:**
|
||||
|
||||
- Dependabot (GitHub)
|
||||
- Snyk (CI/CD integration)
|
||||
|
||||
@@ -1295,12 +1378,14 @@ npm audit fix
|
||||
### 13.1 Why Next.js App Router?
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Server Components reduce client bundle
|
||||
- Better data fetching patterns
|
||||
- Streaming and Suspense built-in
|
||||
- Simpler layouts and error handling
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Newer, less mature than Pages Router
|
||||
- Learning curve for team
|
||||
|
||||
@@ -1309,11 +1394,13 @@ npm audit fix
|
||||
### 13.2 Why TanStack Query?
|
||||
|
||||
**Alternatives Considered:**
|
||||
|
||||
- SWR: Similar but less features
|
||||
- Redux Toolkit Query: Too much boilerplate for our use case
|
||||
- Apollo Client: Overkill for REST API
|
||||
|
||||
**Why TanStack Query:**
|
||||
|
||||
- Best-in-class caching and refetching
|
||||
- Framework-agnostic (not tied to Next.js)
|
||||
- Excellent DevTools
|
||||
@@ -1322,11 +1409,13 @@ npm audit fix
|
||||
### 13.3 Why Zustand over Redux?
|
||||
|
||||
**Why NOT Redux:**
|
||||
|
||||
- Too much boilerplate (actions, reducers, middleware)
|
||||
- We don't need time-travel debugging
|
||||
- Most state is server state (handled by React Query)
|
||||
|
||||
**Why Zustand:**
|
||||
|
||||
- Minimal API (easy to learn)
|
||||
- No Context API overhead
|
||||
- Can use outside React (interceptors)
|
||||
@@ -1335,11 +1424,13 @@ npm audit fix
|
||||
### 13.4 Why shadcn/ui over Component Libraries?
|
||||
|
||||
**Alternatives Considered:**
|
||||
|
||||
- Material-UI: Heavy, opinionated styling
|
||||
- Chakra UI: Good, but still an npm dependency
|
||||
- Ant Design: Too opinionated for template
|
||||
|
||||
**Why shadcn/ui:**
|
||||
|
||||
- Copy-paste (full control)
|
||||
- Accessible (Radix UI primitives)
|
||||
- Tailwind-based (consistent with our stack)
|
||||
@@ -1348,11 +1439,13 @@ npm audit fix
|
||||
### 13.5 Why Axios over Fetch?
|
||||
|
||||
**Why NOT Fetch:**
|
||||
|
||||
- No request/response interceptors
|
||||
- Manual timeout handling
|
||||
- Less ergonomic error handling
|
||||
|
||||
**Why Axios:**
|
||||
|
||||
- Interceptors (essential for auth)
|
||||
- Automatic JSON parsing
|
||||
- Better error handling
|
||||
@@ -1364,11 +1457,13 @@ npm audit fix
|
||||
**Decision: httpOnly Cookies (Primary), localStorage (Fallback)**
|
||||
|
||||
**Why httpOnly Cookies:**
|
||||
|
||||
- Most secure (not accessible to JavaScript)
|
||||
- Prevents XSS token theft
|
||||
- Automatic sending with requests (if CORS configured)
|
||||
|
||||
**Why Fallback to localStorage:**
|
||||
|
||||
- Simpler initial setup (no backend cookie handling)
|
||||
- Still secure with proper measures:
|
||||
- Short access token expiry (15 min)
|
||||
@@ -1377,6 +1472,7 @@ npm audit fix
|
||||
- Encrypted wrapper (optional)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- Try httpOnly cookies first
|
||||
- Fall back to localStorage if not feasible
|
||||
- Document choice in code
|
||||
@@ -1388,12 +1484,14 @@ npm audit fix
|
||||
### 14.1 Production Deployment
|
||||
|
||||
**Recommended Platform: Vercel**
|
||||
|
||||
- Native Next.js support
|
||||
- Edge functions for middleware
|
||||
- Automatic preview deployments
|
||||
- CDN with global edge network
|
||||
|
||||
**Alternative: Docker**
|
||||
|
||||
```dockerfile
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
@@ -1408,18 +1506,21 @@ CMD ["npm", "start"]
|
||||
### 14.2 Environment Configuration
|
||||
|
||||
**Development:**
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
**Production:**
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com/api/v1
|
||||
NODE_ENV=production
|
||||
```
|
||||
|
||||
**Secrets:**
|
||||
|
||||
- Never commit `.env.local`
|
||||
- Use platform-specific secret management (Vercel Secrets, Docker Secrets)
|
||||
|
||||
@@ -1453,6 +1554,7 @@ jobs:
|
||||
This architecture document provides a comprehensive overview of the frontend system design, patterns, and decisions. It should serve as a reference for developers working on the project and guide future architectural decisions.
|
||||
|
||||
For specific implementation details, refer to:
|
||||
|
||||
- **CODING_STANDARDS.md**: Code style and conventions
|
||||
- **COMPONENT_GUIDE.md**: Component usage and patterns
|
||||
- **FEATURE_EXAMPLES.md**: Step-by-step feature implementation
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
### 1.1 General Principles
|
||||
|
||||
**✅ DO:**
|
||||
|
||||
- Enable strict mode in `tsconfig.json`
|
||||
- Define explicit types for all function parameters and return values
|
||||
- Use TypeScript's type inference where obvious
|
||||
@@ -38,6 +39,7 @@
|
||||
- Use generics for reusable, type-safe components and functions
|
||||
|
||||
**❌ DON'T:**
|
||||
|
||||
- Use `any` (use `unknown` if type is truly unknown)
|
||||
- Use `as any` casts (refactor to proper types)
|
||||
- Use `@ts-ignore` or `@ts-nocheck` (fix the underlying issue)
|
||||
@@ -46,6 +48,7 @@
|
||||
### 1.2 Interfaces vs Types
|
||||
|
||||
**Use `interface` for object shapes:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
interface User {
|
||||
@@ -63,6 +66,7 @@ interface UserFormProps {
|
||||
```
|
||||
|
||||
**Use `type` for unions, intersections, and primitives:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
type UserRole = 'admin' | 'user' | 'guest';
|
||||
@@ -74,6 +78,7 @@ type UserWithPermissions = User & { permissions: string[] };
|
||||
### 1.3 Function Signatures
|
||||
|
||||
**Always type function parameters and return values:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
function formatUserName(user: User): string {
|
||||
@@ -86,7 +91,8 @@ async function fetchUser(id: string): Promise<User> {
|
||||
}
|
||||
|
||||
// ❌ Bad
|
||||
function formatUserName(user) { // Implicit any
|
||||
function formatUserName(user) {
|
||||
// Implicit any
|
||||
return `${user.firstName} ${user.lastName}`;
|
||||
}
|
||||
```
|
||||
@@ -94,6 +100,7 @@ function formatUserName(user) { // Implicit any
|
||||
### 1.4 Generics
|
||||
|
||||
**Use generics for reusable, type-safe code:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Generic data table
|
||||
interface DataTableProps<T> {
|
||||
@@ -113,6 +120,7 @@ export function DataTable<T>({ data, columns, onRowClick }: DataTableProps<T>) {
|
||||
### 1.5 Unknown vs Any
|
||||
|
||||
**Use `unknown` for truly unknown types:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Force type checking
|
||||
function parseJSON(jsonString: string): unknown {
|
||||
@@ -134,15 +142,11 @@ function parseJSON(jsonString: string): any {
|
||||
### 1.6 Type Guards
|
||||
|
||||
**Create type guards for runtime type checking:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
function isUser(value: unknown): value is User {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'id' in value &&
|
||||
'email' in value
|
||||
);
|
||||
return typeof value === 'object' && value !== null && 'id' in value && 'email' in value;
|
||||
}
|
||||
|
||||
// Usage
|
||||
@@ -155,6 +159,7 @@ if (isUser(data)) {
|
||||
### 1.7 Utility Types
|
||||
|
||||
**Use TypeScript utility types:**
|
||||
|
||||
```typescript
|
||||
// Partial - make all properties optional
|
||||
type UserUpdate = Partial<User>;
|
||||
@@ -182,6 +187,7 @@ type NonAdminRole = Exclude<UserRole, 'admin'>;
|
||||
### 2.1 Component Structure
|
||||
|
||||
**Standard component template:**
|
||||
|
||||
```typescript
|
||||
// 1. Imports (React, external libs, internal, types, styles)
|
||||
'use client'; // If client component
|
||||
@@ -240,6 +246,7 @@ export function UserList({
|
||||
### 2.2 Server Components vs Client Components
|
||||
|
||||
**Server Components by default:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Server Component (default)
|
||||
// app/(authenticated)/users/page.tsx
|
||||
@@ -255,6 +262,7 @@ export default async function UsersPage() {
|
||||
```
|
||||
|
||||
**Client Components only when needed:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Client Component (interactive)
|
||||
'use client';
|
||||
@@ -276,6 +284,7 @@ export function UserList() {
|
||||
```
|
||||
|
||||
**When to use Client Components:**
|
||||
|
||||
- Using React hooks (useState, useEffect, etc.)
|
||||
- Event handlers (onClick, onChange, etc.)
|
||||
- Browser APIs (window, document, localStorage)
|
||||
@@ -284,6 +293,7 @@ export function UserList() {
|
||||
### 2.3 Props
|
||||
|
||||
**Always type props explicitly:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Explicit interface
|
||||
interface ButtonProps {
|
||||
@@ -300,13 +310,14 @@ export function Button({
|
||||
onClick,
|
||||
variant = 'primary',
|
||||
disabled = false,
|
||||
className
|
||||
className,
|
||||
}: ButtonProps) {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
**Destructure props in function signature:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
function UserCard({ user, onEdit }: UserCardProps) {
|
||||
@@ -320,6 +331,7 @@ function UserCard(props: UserCardProps) {
|
||||
```
|
||||
|
||||
**Allow className override:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Allow consumers to add classes
|
||||
interface CardProps {
|
||||
@@ -339,6 +351,7 @@ export function Card({ title, className }: CardProps) {
|
||||
### 2.4 Composition Over Prop Drilling
|
||||
|
||||
**Use composition patterns:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Composition
|
||||
<Card>
|
||||
@@ -366,6 +379,7 @@ export function Card({ title, className }: CardProps) {
|
||||
### 2.5 Named Exports vs Default Exports
|
||||
|
||||
**Prefer named exports:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Named export
|
||||
export function UserList() {
|
||||
@@ -385,6 +399,7 @@ import WhateverName from './UserList';
|
||||
```
|
||||
|
||||
**Exception: Next.js pages must use default export**
|
||||
|
||||
```typescript
|
||||
// pages and route handlers require default export
|
||||
export default function UsersPage() {
|
||||
@@ -395,6 +410,7 @@ export default function UsersPage() {
|
||||
### 2.6 Custom Hooks
|
||||
|
||||
**Extract reusable logic:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Custom hook
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
@@ -425,15 +441,16 @@ function SearchInput() {
|
||||
```
|
||||
|
||||
**Hook naming: Always prefix with "use":**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
function useAuth() { }
|
||||
function useUsers() { }
|
||||
function useDebounce() { }
|
||||
function useAuth() {}
|
||||
function useUsers() {}
|
||||
function useDebounce() {}
|
||||
|
||||
// ❌ Bad
|
||||
function auth() { }
|
||||
function getUsers() { }
|
||||
function auth() {}
|
||||
function getUsers() {}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -442,20 +459,21 @@ function getUsers() { }
|
||||
|
||||
### 3.1 Files and Directories
|
||||
|
||||
| Type | Convention | Example |
|
||||
|------|------------|---------|
|
||||
| Components | PascalCase | `UserTable.tsx`, `LoginForm.tsx` |
|
||||
| Hooks | camelCase with `use` prefix | `useAuth.ts`, `useDebounce.ts` |
|
||||
| Utilities | camelCase | `formatDate.ts`, `parseError.ts` |
|
||||
| Types | camelCase | `user.ts`, `api.ts` |
|
||||
| Constants | camelCase or UPPER_SNAKE_CASE | `constants.ts`, `API_ENDPOINTS.ts` |
|
||||
| Stores | camelCase with `Store` suffix | `authStore.ts`, `uiStore.ts` |
|
||||
| Services | camelCase with `Service` suffix | `authService.ts`, `adminService.ts` |
|
||||
| Pages (Next.js) | lowercase | `page.tsx`, `layout.tsx`, `loading.tsx` |
|
||||
| Type | Convention | Example |
|
||||
| --------------- | ------------------------------- | --------------------------------------- |
|
||||
| Components | PascalCase | `UserTable.tsx`, `LoginForm.tsx` |
|
||||
| Hooks | camelCase with `use` prefix | `useAuth.ts`, `useDebounce.ts` |
|
||||
| Utilities | camelCase | `formatDate.ts`, `parseError.ts` |
|
||||
| Types | camelCase | `user.ts`, `api.ts` |
|
||||
| Constants | camelCase or UPPER_SNAKE_CASE | `constants.ts`, `API_ENDPOINTS.ts` |
|
||||
| Stores | camelCase with `Store` suffix | `authStore.ts`, `uiStore.ts` |
|
||||
| Services | camelCase with `Service` suffix | `authService.ts`, `adminService.ts` |
|
||||
| Pages (Next.js) | lowercase | `page.tsx`, `layout.tsx`, `loading.tsx` |
|
||||
|
||||
### 3.2 Variables and Functions
|
||||
|
||||
**Variables:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: camelCase
|
||||
const userName = 'John';
|
||||
@@ -463,36 +481,39 @@ const isAuthenticated = true;
|
||||
const userList = [];
|
||||
|
||||
// ❌ Bad
|
||||
const UserName = 'John'; // PascalCase for variable
|
||||
const UserName = 'John'; // PascalCase for variable
|
||||
const user_name = 'John'; // snake_case
|
||||
```
|
||||
|
||||
**Functions:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: camelCase, descriptive verb + noun
|
||||
function getUserById(id: string): User { }
|
||||
function formatDate(date: Date): string { }
|
||||
function handleSubmit(data: FormData): void { }
|
||||
function getUserById(id: string): User {}
|
||||
function formatDate(date: Date): string {}
|
||||
function handleSubmit(data: FormData): void {}
|
||||
|
||||
// ❌ Bad
|
||||
function User(id: string) { } // Looks like a class
|
||||
function get_user(id: string) { } // snake_case
|
||||
function gub(id: string) { } // Not descriptive
|
||||
function User(id: string) {} // Looks like a class
|
||||
function get_user(id: string) {} // snake_case
|
||||
function gub(id: string) {} // Not descriptive
|
||||
```
|
||||
|
||||
**Event Handlers:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: handle + EventName
|
||||
const handleClick = () => { };
|
||||
const handleSubmit = () => { };
|
||||
const handleInputChange = () => { };
|
||||
const handleClick = () => {};
|
||||
const handleSubmit = () => {};
|
||||
const handleInputChange = () => {};
|
||||
|
||||
// ❌ Bad
|
||||
const onClick = () => { }; // Confusing with prop name
|
||||
const submit = () => { };
|
||||
const onClick = () => {}; // Confusing with prop name
|
||||
const submit = () => {};
|
||||
```
|
||||
|
||||
**Boolean Variables:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: is/has/should prefix
|
||||
const isLoading = true;
|
||||
@@ -508,6 +529,7 @@ const error = false;
|
||||
### 3.3 Constants
|
||||
|
||||
**Use UPPER_SNAKE_CASE for true constants:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
const MAX_RETRY_ATTEMPTS = 3;
|
||||
@@ -525,16 +547,17 @@ const USER_ROLES = {
|
||||
### 3.4 Types and Interfaces
|
||||
|
||||
**PascalCase for types and interfaces:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
interface User { }
|
||||
interface UserFormProps { }
|
||||
interface User {}
|
||||
interface UserFormProps {}
|
||||
type UserId = string;
|
||||
type UserRole = 'admin' | 'user';
|
||||
|
||||
// ❌ Bad
|
||||
interface user { }
|
||||
interface user_form_props { }
|
||||
interface user {}
|
||||
interface user_form_props {}
|
||||
type userId = string;
|
||||
```
|
||||
|
||||
@@ -545,6 +568,7 @@ type userId = string;
|
||||
### 4.1 Import Order
|
||||
|
||||
**Organize imports in this order:**
|
||||
|
||||
```typescript
|
||||
// 1. React and Next.js
|
||||
import { useState, useEffect } from 'react';
|
||||
@@ -576,6 +600,7 @@ import styles from './Component.module.css';
|
||||
### 4.2 Co-location
|
||||
|
||||
**Group related files together:**
|
||||
|
||||
```
|
||||
components/admin/
|
||||
├── UserTable/
|
||||
@@ -587,6 +612,7 @@ components/admin/
|
||||
```
|
||||
|
||||
**Or flat structure for simpler components:**
|
||||
|
||||
```
|
||||
components/admin/
|
||||
├── UserTable.tsx
|
||||
@@ -598,6 +624,7 @@ components/admin/
|
||||
### 4.3 Barrel Exports
|
||||
|
||||
**Use index.ts for clean imports:**
|
||||
|
||||
```typescript
|
||||
// components/ui/index.ts
|
||||
export { Button } from './button';
|
||||
@@ -615,6 +642,7 @@ import { Button, Card, Input } from '@/components/ui';
|
||||
### 5.1 State Placement
|
||||
|
||||
**Keep state as local as possible:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Local state
|
||||
function UserFilter() {
|
||||
@@ -629,6 +657,7 @@ function UserFilter() {
|
||||
### 5.2 TanStack Query Usage
|
||||
|
||||
**Standard query pattern:**
|
||||
|
||||
```typescript
|
||||
// lib/api/hooks/useUsers.ts
|
||||
export function useUsers(filters?: UserFilters) {
|
||||
@@ -651,6 +680,7 @@ function UserList() {
|
||||
```
|
||||
|
||||
**Standard mutation pattern:**
|
||||
|
||||
```typescript
|
||||
// lib/api/hooks/useUsers.ts
|
||||
export function useUpdateUser() {
|
||||
@@ -691,22 +721,21 @@ function UserForm({ userId }: { userId: string }) {
|
||||
```
|
||||
|
||||
**Query key structure:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Consistent query keys
|
||||
['users'] // List all
|
||||
['users', userId] // Single user
|
||||
['users', { search: 'john', page: 1 }] // Filtered list
|
||||
['organizations', orgId, 'members'] // Nested resource
|
||||
|
||||
// ❌ Bad: Inconsistent
|
||||
['userList']
|
||||
['user-' + userId]
|
||||
['getUsersBySearch', 'john']
|
||||
['users'][('users', userId)][('users', { search: 'john', page: 1 })][ // List all // Single user // Filtered list
|
||||
('organizations', orgId, 'members')
|
||||
][ // Nested resource
|
||||
// ❌ Bad: Inconsistent
|
||||
'userList'
|
||||
]['user-' + userId][('getUsersBySearch', 'john')];
|
||||
```
|
||||
|
||||
### 5.3 Zustand Store Pattern
|
||||
|
||||
**Auth store example:**
|
||||
|
||||
```typescript
|
||||
// stores/authStore.ts
|
||||
import { create } from 'zustand';
|
||||
@@ -757,6 +786,7 @@ function UserAvatar() {
|
||||
```
|
||||
|
||||
**UI store example:**
|
||||
|
||||
```typescript
|
||||
// stores/uiStore.ts
|
||||
interface UIStore {
|
||||
@@ -790,6 +820,7 @@ export const useUIStore = create<UIStore>()(
|
||||
### 6.1 API Client Structure
|
||||
|
||||
**Axios instance configuration:**
|
||||
|
||||
```typescript
|
||||
// lib/api/client.ts
|
||||
import axios from 'axios';
|
||||
@@ -837,6 +868,7 @@ apiClient.interceptors.response.use(
|
||||
### 6.2 Error Handling
|
||||
|
||||
**Parse API errors:**
|
||||
|
||||
```typescript
|
||||
// lib/api/errors.ts
|
||||
export interface APIError {
|
||||
@@ -850,18 +882,20 @@ export function parseAPIError(error: AxiosError): APIError[] {
|
||||
return error.response.data.errors;
|
||||
}
|
||||
|
||||
return [{
|
||||
code: 'UNKNOWN',
|
||||
message: error.message || 'An unexpected error occurred',
|
||||
}];
|
||||
return [
|
||||
{
|
||||
code: 'UNKNOWN',
|
||||
message: error.message || 'An unexpected error occurred',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Error code mapping
|
||||
export const ERROR_MESSAGES: Record<string, string> = {
|
||||
'AUTH_001': 'Invalid email or password',
|
||||
'USER_002': 'This email is already registered',
|
||||
'VAL_001': 'Please check your input',
|
||||
'ORG_001': 'Organization name already exists',
|
||||
AUTH_001: 'Invalid email or password',
|
||||
USER_002: 'This email is already registered',
|
||||
VAL_001: 'Please check your input',
|
||||
ORG_001: 'Organization name already exists',
|
||||
};
|
||||
|
||||
export function getErrorMessage(code: string): string {
|
||||
@@ -872,18 +906,19 @@ export function getErrorMessage(code: string): string {
|
||||
### 6.3 Hook Organization
|
||||
|
||||
**One hook file per resource:**
|
||||
|
||||
```typescript
|
||||
// lib/api/hooks/useUsers.ts
|
||||
export function useUsers(filters?: UserFilters) { }
|
||||
export function useUser(userId: string) { }
|
||||
export function useCreateUser() { }
|
||||
export function useUpdateUser() { }
|
||||
export function useDeleteUser() { }
|
||||
export function useUsers(filters?: UserFilters) {}
|
||||
export function useUser(userId: string) {}
|
||||
export function useCreateUser() {}
|
||||
export function useUpdateUser() {}
|
||||
export function useDeleteUser() {}
|
||||
|
||||
// lib/api/hooks/useOrganizations.ts
|
||||
export function useOrganizations() { }
|
||||
export function useOrganization(orgId: string) { }
|
||||
export function useCreateOrganization() { }
|
||||
export function useOrganizations() {}
|
||||
export function useOrganization(orgId: string) {}
|
||||
export function useCreateOrganization() {}
|
||||
// ...
|
||||
```
|
||||
|
||||
@@ -894,6 +929,7 @@ export function useCreateOrganization() { }
|
||||
### 7.1 Form Pattern with react-hook-form + Zod
|
||||
|
||||
**Standard form implementation:**
|
||||
|
||||
```typescript
|
||||
// components/auth/LoginForm.tsx
|
||||
'use client';
|
||||
@@ -961,30 +997,34 @@ export function LoginForm() {
|
||||
### 7.2 Form Validation
|
||||
|
||||
**Complex validation with Zod:**
|
||||
|
||||
```typescript
|
||||
const userSchema = z.object({
|
||||
email: z.string().email('Invalid email'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Min 8 characters')
|
||||
.regex(/[A-Z]/, 'Must contain uppercase')
|
||||
.regex(/[0-9]/, 'Must contain number'),
|
||||
confirmPassword: z.string(),
|
||||
firstName: z.string().min(1, 'Required'),
|
||||
lastName: z.string().optional(),
|
||||
phoneNumber: z
|
||||
.string()
|
||||
.regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number')
|
||||
.optional(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
const userSchema = z
|
||||
.object({
|
||||
email: z.string().email('Invalid email'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Min 8 characters')
|
||||
.regex(/[A-Z]/, 'Must contain uppercase')
|
||||
.regex(/[0-9]/, 'Must contain number'),
|
||||
confirmPassword: z.string(),
|
||||
firstName: z.string().min(1, 'Required'),
|
||||
lastName: z.string().optional(),
|
||||
phoneNumber: z
|
||||
.string()
|
||||
.regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number')
|
||||
.optional(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
```
|
||||
|
||||
### 7.3 Form Accessibility
|
||||
|
||||
**Always include labels and error messages:**
|
||||
|
||||
```typescript
|
||||
<div>
|
||||
<Label htmlFor="email">Email</Label>
|
||||
@@ -1009,6 +1049,7 @@ const userSchema = z.object({
|
||||
### 8.1 Tailwind CSS Usage
|
||||
|
||||
**Use utility classes:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
<button className="px-4 py-2 bg-primary text-white rounded-md hover:bg-primary/90">
|
||||
@@ -1022,6 +1063,7 @@ const userSchema = z.object({
|
||||
```
|
||||
|
||||
**Use cn() for conditional classes:**
|
||||
|
||||
```typescript
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
@@ -1036,6 +1078,7 @@ import { cn } from '@/lib/utils/cn';
|
||||
### 8.2 Responsive Design
|
||||
|
||||
**Mobile-first approach:**
|
||||
|
||||
```typescript
|
||||
<div className="
|
||||
w-full p-4 /* Mobile */
|
||||
@@ -1050,6 +1093,7 @@ import { cn } from '@/lib/utils/cn';
|
||||
### 8.3 Dark Mode
|
||||
|
||||
**Use dark mode classes:**
|
||||
|
||||
```typescript
|
||||
<div className="
|
||||
bg-white text-black
|
||||
@@ -1079,6 +1123,7 @@ src/
|
||||
### 10.2 Test Naming
|
||||
|
||||
**Use descriptive test names:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
test('displays user list when data is loaded', async () => {});
|
||||
@@ -1095,6 +1140,7 @@ test('renders', () => {});
|
||||
### 10.3 Component Testing
|
||||
|
||||
**Test user interactions, not implementation:**
|
||||
|
||||
```typescript
|
||||
// UserTable.test.tsx
|
||||
import { render, screen, userEvent } from '@testing-library/react';
|
||||
@@ -1115,6 +1161,7 @@ test('allows user to search for users', async () => {
|
||||
### 10.4 Accessibility Testing
|
||||
|
||||
**Test with accessibility queries:**
|
||||
|
||||
```typescript
|
||||
// Prefer getByRole over getByTestId
|
||||
const button = screen.getByRole('button', { name: 'Submit' });
|
||||
@@ -1156,6 +1203,7 @@ const textbox = screen.getByRole('textbox', { name: 'Email' });
|
||||
### 11.2 ARIA Labels
|
||||
|
||||
**Use ARIA when semantic HTML isn't enough:**
|
||||
|
||||
```typescript
|
||||
<button aria-label="Close dialog">
|
||||
<X className="w-4 h-4" />
|
||||
@@ -1169,6 +1217,7 @@ const textbox = screen.getByRole('textbox', { name: 'Email' });
|
||||
### 11.3 Keyboard Navigation
|
||||
|
||||
**Ensure all interactive elements are keyboard accessible:**
|
||||
|
||||
```typescript
|
||||
<div
|
||||
role="button"
|
||||
@@ -1191,6 +1240,7 @@ const textbox = screen.getByRole('textbox', { name: 'Email' });
|
||||
### 12.1 Code Splitting
|
||||
|
||||
**Dynamic imports for heavy components:**
|
||||
|
||||
```typescript
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
@@ -1203,6 +1253,7 @@ const HeavyChart = dynamic(() => import('./HeavyChart'), {
|
||||
### 12.2 Memoization
|
||||
|
||||
**Use React.memo for expensive renders:**
|
||||
|
||||
```typescript
|
||||
export const UserCard = React.memo(function UserCard({ user }: UserCardProps) {
|
||||
return <div>{user.name}</div>;
|
||||
@@ -1210,6 +1261,7 @@ export const UserCard = React.memo(function UserCard({ user }: UserCardProps) {
|
||||
```
|
||||
|
||||
**Use useMemo for expensive calculations:**
|
||||
|
||||
```typescript
|
||||
const sortedUsers = useMemo(() => {
|
||||
return users.sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -1219,6 +1271,7 @@ const sortedUsers = useMemo(() => {
|
||||
### 12.3 Image Optimization
|
||||
|
||||
**Always use Next.js Image component:**
|
||||
|
||||
```typescript
|
||||
import Image from 'next/image';
|
||||
|
||||
@@ -1238,6 +1291,7 @@ import Image from 'next/image';
|
||||
### 13.1 Input Sanitization
|
||||
|
||||
**Never render raw HTML:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: React escapes by default
|
||||
<div>{userInput}</div>
|
||||
@@ -1249,6 +1303,7 @@ import Image from 'next/image';
|
||||
### 13.2 Environment Variables
|
||||
|
||||
**Never commit secrets:**
|
||||
|
||||
```typescript
|
||||
// ✅ Good: Use env variables
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
||||
@@ -1258,6 +1313,7 @@ const apiUrl = 'https://api.example.com';
|
||||
```
|
||||
|
||||
**Public vs Private:**
|
||||
|
||||
- `NEXT_PUBLIC_*`: Exposed to browser
|
||||
- Other vars: Server-side only
|
||||
|
||||
@@ -1266,6 +1322,7 @@ const apiUrl = 'https://api.example.com';
|
||||
## 14. Code Review Checklist
|
||||
|
||||
**Before submitting PR:**
|
||||
|
||||
- [ ] All tests pass
|
||||
- [ ] No TypeScript errors
|
||||
- [ ] ESLint passes
|
||||
@@ -1285,6 +1342,7 @@ const apiUrl = 'https://api.example.com';
|
||||
These standards ensure consistency, maintainability, and quality across the codebase. Follow them rigorously, and update this document as the project evolves.
|
||||
|
||||
For specific patterns and examples, refer to:
|
||||
|
||||
- **ARCHITECTURE.md**: System design and patterns
|
||||
- **COMPONENT_GUIDE.md**: Component usage and examples
|
||||
- **FEATURE_EXAMPLES.md**: Step-by-step implementation guides
|
||||
|
||||
@@ -27,44 +27,48 @@
|
||||
### Pitfall 1.1: Returning Hook Function Instead of Calling It
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
// Custom hook that wraps Zustand
|
||||
export function useAuth() {
|
||||
const storeHook = useContext(AuthContext);
|
||||
return storeHook; // Returns the hook function itself!
|
||||
return storeHook; // Returns the hook function itself!
|
||||
}
|
||||
|
||||
// Consumer component
|
||||
function MyComponent() {
|
||||
const authHook = useAuth(); // Got the hook function
|
||||
const { user } = authHook(); // Have to call it here ❌ Rules of Hooks violation!
|
||||
const authHook = useAuth(); // Got the hook function
|
||||
const { user } = authHook(); // Have to call it here ❌ Rules of Hooks violation!
|
||||
}
|
||||
```
|
||||
|
||||
**Why It's Wrong:**
|
||||
|
||||
- Violates React Rules of Hooks (hook called conditionally/in wrong place)
|
||||
- Confusing API for consumers
|
||||
- Can't use in conditionals or callbacks safely
|
||||
- Type inference breaks
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
// Custom hook that calls the wrapped hook internally
|
||||
export function useAuth() {
|
||||
const storeHook = useContext(AuthContext);
|
||||
if (!storeHook) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return storeHook(); // Call the hook HERE, return the state
|
||||
return storeHook(); // Call the hook HERE, return the state
|
||||
}
|
||||
|
||||
// Consumer component
|
||||
function MyComponent() {
|
||||
const { user } = useAuth(); // Direct access to state ✅
|
||||
const { user } = useAuth(); // Direct access to state ✅
|
||||
}
|
||||
```
|
||||
|
||||
**✅ EVEN BETTER (Polymorphic):**
|
||||
|
||||
```typescript
|
||||
// Support both patterns
|
||||
export function useAuth(): AuthState;
|
||||
@@ -72,17 +76,18 @@ export function useAuth<T>(selector: (state: AuthState) => T): T;
|
||||
export function useAuth<T>(selector?: (state: AuthState) => T): AuthState | T {
|
||||
const storeHook = useContext(AuthContext);
|
||||
if (!storeHook) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return selector ? storeHook(selector) : storeHook();
|
||||
}
|
||||
|
||||
// Usage - both work!
|
||||
const { user } = useAuth(); // Full state
|
||||
const user = useAuth(s => s.user); // Optimized selector
|
||||
const { user } = useAuth(); // Full state
|
||||
const user = useAuth((s) => s.user); // Optimized selector
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Always call hooks internally in custom hooks**
|
||||
- Return state/values, not hook functions
|
||||
- Support selectors for performance optimization
|
||||
@@ -92,6 +97,7 @@ const user = useAuth(s => s.user); // Optimized selector
|
||||
### Pitfall 1.2: Calling Hooks Conditionally
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
function MyComponent({ showUser }) {
|
||||
if (showUser) {
|
||||
@@ -103,6 +109,7 @@ function MyComponent({ showUser }) {
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
function MyComponent({ showUser }) {
|
||||
const { user } = useAuth(); // ✅ Always call at top level
|
||||
@@ -116,6 +123,7 @@ function MyComponent({ showUser }) {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Always call hooks at the top level of your component**
|
||||
- Never call hooks inside conditionals, loops, or nested functions
|
||||
- Return early after hooks are called
|
||||
@@ -127,6 +135,7 @@ function MyComponent({ showUser }) {
|
||||
### Pitfall 2.1: Creating New Context Value on Every Render
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
@@ -139,11 +148,13 @@ export function AuthProvider({ children }) {
|
||||
```
|
||||
|
||||
**Why It's Wrong:**
|
||||
|
||||
- Every render creates a new object
|
||||
- All consumers re-render even if values unchanged
|
||||
- Performance nightmare in large apps
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
@@ -156,6 +167,7 @@ export function AuthProvider({ children }) {
|
||||
```
|
||||
|
||||
**✅ EVEN BETTER (Zustand + Context):**
|
||||
|
||||
```typescript
|
||||
export function AuthProvider({ children, store }) {
|
||||
// Zustand hook function is stable (doesn't change)
|
||||
@@ -167,6 +179,7 @@ export function AuthProvider({ children, store }) {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Use `useMemo` for Context values that are objects**
|
||||
- Or use stable references (Zustand hooks, refs)
|
||||
- Monitor re-renders with React DevTools
|
||||
@@ -176,6 +189,7 @@ export function AuthProvider({ children, store }) {
|
||||
### Pitfall 2.2: Prop Drilling Instead of Context
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
// Passing through 5 levels
|
||||
<Layout user={user}>
|
||||
@@ -190,6 +204,7 @@ export function AuthProvider({ children, store }) {
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
// Provider at top
|
||||
<AuthProvider>
|
||||
@@ -206,6 +221,7 @@ export function AuthProvider({ children, store }) {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Use Context for data needed by many components**
|
||||
- Avoid prop drilling beyond 2-3 levels
|
||||
- But don't overuse - local state is often better
|
||||
@@ -217,6 +233,7 @@ export function AuthProvider({ children, store }) {
|
||||
### Pitfall 3.1: Mixing Render State Access and Mutation Logic
|
||||
|
||||
**❌ WRONG (Mixing patterns):**
|
||||
|
||||
```typescript
|
||||
function MyComponent() {
|
||||
// Using hook for render state
|
||||
@@ -231,6 +248,7 @@ function MyComponent() {
|
||||
```
|
||||
|
||||
**✅ CORRECT (Separate patterns):**
|
||||
|
||||
```typescript
|
||||
function MyComponent() {
|
||||
// Hook for render state (subscribes to changes)
|
||||
@@ -245,12 +263,14 @@ function MyComponent() {
|
||||
```
|
||||
|
||||
**Why This Pattern?**
|
||||
|
||||
- **Render state**: Use hook → component re-renders on changes
|
||||
- **Mutations**: Use `getState()` → no subscription, no re-renders
|
||||
- **Performance**: Event handlers don't need to subscribe
|
||||
- **Clarity**: Clear distinction between read and write
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Use hooks for state that affects rendering**
|
||||
- **Use `getState()` for mutations in callbacks**
|
||||
- Don't subscribe when you don't need to
|
||||
@@ -260,6 +280,7 @@ function MyComponent() {
|
||||
### Pitfall 3.2: Not Using Selectors for Optimization
|
||||
|
||||
**❌ SUBOPTIMAL:**
|
||||
|
||||
```typescript
|
||||
function UserAvatar() {
|
||||
// Re-renders on ANY auth state change! ❌
|
||||
@@ -270,6 +291,7 @@ function UserAvatar() {
|
||||
```
|
||||
|
||||
**✅ OPTIMIZED:**
|
||||
|
||||
```typescript
|
||||
function UserAvatar() {
|
||||
// Only re-renders when user changes ✅
|
||||
@@ -280,6 +302,7 @@ function UserAvatar() {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Use selectors for components that only need subset of state**
|
||||
- Reduces unnecessary re-renders
|
||||
- Especially important in frequently updating stores
|
||||
@@ -291,13 +314,16 @@ function UserAvatar() {
|
||||
### Pitfall 4.1: Using `any` Type
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
function processUser(user: any) { // ❌ Loses all type safety
|
||||
return user.name.toUpperCase(); // No error if user.name is undefined
|
||||
function processUser(user: any) {
|
||||
// ❌ Loses all type safety
|
||||
return user.name.toUpperCase(); // No error if user.name is undefined
|
||||
}
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
function processUser(user: User | null) {
|
||||
if (!user?.name) {
|
||||
@@ -308,6 +334,7 @@ function processUser(user: User | null) {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Never use `any` - use `unknown` if type is truly unknown**
|
||||
- Define proper types for all function parameters
|
||||
- Use type guards for runtime checks
|
||||
@@ -317,15 +344,17 @@ function processUser(user: User | null) {
|
||||
### Pitfall 4.2: Implicit Types Leading to Errors
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
// No explicit return type - type inference can be wrong
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
return context; // What type is this? ❌
|
||||
return context; // What type is this? ❌
|
||||
}
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
// Explicit return type with overloads
|
||||
export function useAuth(): AuthState;
|
||||
@@ -333,13 +362,14 @@ export function useAuth<T>(selector: (state: AuthState) => T): T;
|
||||
export function useAuth<T>(selector?: (state: AuthState) => T): AuthState | T {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return selector ? context(selector) : context();
|
||||
}
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Always provide explicit return types for public APIs**
|
||||
- Use function overloads for polymorphic functions
|
||||
- Document types in JSDoc comments
|
||||
@@ -349,16 +379,19 @@ export function useAuth<T>(selector?: (state: AuthState) => T): AuthState | T {
|
||||
### Pitfall 4.3: Not Using `import type` for Type-Only Imports
|
||||
|
||||
**❌ SUBOPTIMAL:**
|
||||
|
||||
```typescript
|
||||
import { ReactNode } from 'react'; // Might be bundled even if only used for types
|
||||
import { ReactNode } from 'react'; // Might be bundled even if only used for types
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
import type { ReactNode } from 'react'; // Guaranteed to be stripped from bundle
|
||||
import type { ReactNode } from 'react'; // Guaranteed to be stripped from bundle
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Use `import type` for type-only imports**
|
||||
- Smaller bundle size
|
||||
- Clearer intent
|
||||
@@ -370,6 +403,7 @@ import type { ReactNode } from 'react'; // Guaranteed to be stripped from bundl
|
||||
### Pitfall 5.1: Forgetting Optional Chaining for Nullable Values
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
function UserProfile() {
|
||||
const { user } = useAuth();
|
||||
@@ -378,6 +412,7 @@ function UserProfile() {
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
function UserProfile() {
|
||||
const { user } = useAuth();
|
||||
@@ -397,6 +432,7 @@ function UserProfile() {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Always handle null/undefined cases**
|
||||
- Use optional chaining (`?.`) and nullish coalescing (`??`)
|
||||
- Provide fallback UI for missing data
|
||||
@@ -406,6 +442,7 @@ function UserProfile() {
|
||||
### Pitfall 5.2: Mixing Concerns in Components
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
function UserDashboard() {
|
||||
const [users, setUsers] = useState([]);
|
||||
@@ -429,6 +466,7 @@ function UserDashboard() {
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
// Custom hook for data fetching
|
||||
function useUsers() {
|
||||
@@ -460,6 +498,7 @@ function UserDashboard() {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Separate concerns: data fetching, business logic, rendering**
|
||||
- Extract logic to custom hooks
|
||||
- Keep components focused on UI
|
||||
@@ -471,6 +510,7 @@ function UserDashboard() {
|
||||
### Pitfall 6.1: Wrong Provider Order
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
// AuthInitializer outside AuthProvider ❌
|
||||
function RootLayout({ children }) {
|
||||
@@ -486,6 +526,7 @@ function RootLayout({ children }) {
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
function RootLayout({ children }) {
|
||||
return (
|
||||
@@ -500,6 +541,7 @@ function RootLayout({ children }) {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Providers must wrap components that use them**
|
||||
- Order matters when there are dependencies
|
||||
- Keep provider tree shallow (performance)
|
||||
@@ -509,6 +551,7 @@ function RootLayout({ children }) {
|
||||
### Pitfall 6.2: Creating Too Many Providers
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
// Separate provider for every piece of state ❌
|
||||
<UserProvider>
|
||||
@@ -525,6 +568,7 @@ function RootLayout({ children }) {
|
||||
```
|
||||
|
||||
**✅ BETTER:**
|
||||
|
||||
```typescript
|
||||
// Combine related state, use Zustand for most things
|
||||
<AuthProvider> {/* Only for auth DI */}
|
||||
@@ -541,6 +585,7 @@ const useUserPreferences = create(...); // User settings
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Use Context only when necessary** (DI, third-party integrations)
|
||||
- **Use Zustand for most global state** (no provider needed)
|
||||
- Avoid provider hell
|
||||
@@ -552,6 +597,7 @@ const useUserPreferences = create(...); // User settings
|
||||
### Pitfall 7.1: Using Hooks in Event Handlers
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
function MyComponent() {
|
||||
const handleClick = () => {
|
||||
@@ -564,6 +610,7 @@ function MyComponent() {
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
function MyComponent() {
|
||||
const { user } = useAuth(); // ✅ Hook at component top level
|
||||
@@ -587,6 +634,7 @@ function MyComponent() {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Never call hooks inside event handlers**
|
||||
- For render state: Call hook at top level, access in closure
|
||||
- For mutations: Use `store.getState().method()`
|
||||
@@ -596,13 +644,15 @@ function MyComponent() {
|
||||
### Pitfall 7.2: Not Handling Async Errors in Event Handlers
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
const handleSubmit = async (data: FormData) => {
|
||||
await apiCall(data); // ❌ No error handling!
|
||||
await apiCall(data); // ❌ No error handling!
|
||||
};
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
const handleSubmit = async (data: FormData) => {
|
||||
try {
|
||||
@@ -616,6 +666,7 @@ const handleSubmit = async (data: FormData) => {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Always wrap async calls in try/catch**
|
||||
- Provide user feedback for both success and errors
|
||||
- Log errors for debugging
|
||||
@@ -627,6 +678,7 @@ const handleSubmit = async (data: FormData) => {
|
||||
### Pitfall 8.1: Not Mocking Context Providers in Tests
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
// Test without provider ❌
|
||||
test('renders user name', () => {
|
||||
@@ -636,6 +688,7 @@ test('renders user name', () => {
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
// Mock the hook
|
||||
jest.mock('@/lib/stores', () => ({
|
||||
@@ -654,6 +707,7 @@ test('renders user name', () => {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Mock hooks at module level in tests**
|
||||
- Provide necessary return values for each test case
|
||||
- Test both success and error states
|
||||
@@ -663,6 +717,7 @@ test('renders user name', () => {
|
||||
### Pitfall 8.2: Testing Implementation Details
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
test('calls useAuthStore hook', () => {
|
||||
const spy = jest.spyOn(require('@/lib/stores'), 'useAuthStore');
|
||||
@@ -672,6 +727,7 @@ test('calls useAuthStore hook', () => {
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
test('displays user name when authenticated', () => {
|
||||
(useAuth as jest.Mock).mockReturnValue({
|
||||
@@ -685,6 +741,7 @@ test('displays user name when authenticated', () => {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Test behavior, not implementation**
|
||||
- Focus on what the user sees/does
|
||||
- Don't test internal API calls unless critical
|
||||
@@ -696,6 +753,7 @@ test('displays user name when authenticated', () => {
|
||||
### Pitfall 9.1: Not Using React.memo for Expensive Components
|
||||
|
||||
**❌ SUBOPTIMAL:**
|
||||
|
||||
```typescript
|
||||
// Re-renders every time parent re-renders ❌
|
||||
function ExpensiveChart({ data }) {
|
||||
@@ -705,6 +763,7 @@ function ExpensiveChart({ data }) {
|
||||
```
|
||||
|
||||
**✅ OPTIMIZED:**
|
||||
|
||||
```typescript
|
||||
// Only re-renders when data changes ✅
|
||||
export const ExpensiveChart = React.memo(function ExpensiveChart({ data }) {
|
||||
@@ -713,6 +772,7 @@ export const ExpensiveChart = React.memo(function ExpensiveChart({ data }) {
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Use `React.memo` for expensive components**
|
||||
- Especially useful for list items, charts, heavy UI
|
||||
- Profile with React DevTools to identify candidates
|
||||
@@ -722,6 +782,7 @@ export const ExpensiveChart = React.memo(function ExpensiveChart({ data }) {
|
||||
### Pitfall 9.2: Creating Functions Inside Render
|
||||
|
||||
**❌ SUBOPTIMAL:**
|
||||
|
||||
```typescript
|
||||
function MyComponent() {
|
||||
return (
|
||||
@@ -733,6 +794,7 @@ function MyComponent() {
|
||||
```
|
||||
|
||||
**✅ OPTIMIZED:**
|
||||
|
||||
```typescript
|
||||
function MyComponent() {
|
||||
const handleClick = useCallback(() => {
|
||||
@@ -744,15 +806,18 @@ function MyComponent() {
|
||||
```
|
||||
|
||||
**When to Optimize:**
|
||||
|
||||
- **For memoized child components** (memo, PureComponent)
|
||||
- **For expensive event handlers**
|
||||
- **When profiling shows performance issues**
|
||||
|
||||
**When NOT to optimize:**
|
||||
|
||||
- **Simple components with cheap operations** (premature optimization)
|
||||
- **One-off event handlers**
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Use `useCallback` for functions passed to memoized children**
|
||||
- But don't optimize everything - profile first
|
||||
|
||||
@@ -763,6 +828,7 @@ function MyComponent() {
|
||||
### Pitfall 10.1: Not Using Barrel Exports
|
||||
|
||||
**❌ INCONSISTENT:**
|
||||
|
||||
```typescript
|
||||
// Deep imports all over the codebase
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
@@ -771,6 +837,7 @@ import { User } from '@/lib/stores/authStore';
|
||||
```
|
||||
|
||||
**✅ CONSISTENT:**
|
||||
|
||||
```typescript
|
||||
// Barrel exports in stores/index.ts
|
||||
export { useAuth, AuthProvider } from '../auth/AuthContext';
|
||||
@@ -781,6 +848,7 @@ import { useAuth, useAuthStore, User } from '@/lib/stores';
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Create barrel exports (index.ts) for public APIs**
|
||||
- Easier to refactor internal structure
|
||||
- Consistent import paths across codebase
|
||||
@@ -790,31 +858,44 @@ import { useAuth, useAuthStore, User } from '@/lib/stores';
|
||||
### Pitfall 10.2: Circular Dependencies
|
||||
|
||||
**❌ WRONG:**
|
||||
|
||||
```typescript
|
||||
// fileA.ts
|
||||
import { functionB } from './fileB';
|
||||
export function functionA() { return functionB(); }
|
||||
export function functionA() {
|
||||
return functionB();
|
||||
}
|
||||
|
||||
// fileB.ts
|
||||
import { functionA } from './fileA'; // ❌ Circular!
|
||||
export function functionB() { return functionA(); }
|
||||
import { functionA } from './fileA'; // ❌ Circular!
|
||||
export function functionB() {
|
||||
return functionA();
|
||||
}
|
||||
```
|
||||
|
||||
**✅ CORRECT:**
|
||||
|
||||
```typescript
|
||||
// utils.ts
|
||||
export function sharedFunction() { /* shared logic */ }
|
||||
export function sharedFunction() {
|
||||
/* shared logic */
|
||||
}
|
||||
|
||||
// fileA.ts
|
||||
import { sharedFunction } from './utils';
|
||||
export function functionA() { return sharedFunction(); }
|
||||
export function functionA() {
|
||||
return sharedFunction();
|
||||
}
|
||||
|
||||
// fileB.ts
|
||||
import { sharedFunction } from './utils';
|
||||
export function functionB() { return sharedFunction(); }
|
||||
export function functionB() {
|
||||
return sharedFunction();
|
||||
}
|
||||
```
|
||||
|
||||
**Key Takeaway:**
|
||||
|
||||
- **Avoid circular imports**
|
||||
- Extract shared code to separate modules
|
||||
- Keep dependency graph acyclic
|
||||
@@ -840,6 +921,7 @@ npm run build
|
||||
```
|
||||
|
||||
**In browser:**
|
||||
|
||||
- [ ] No console errors or warnings
|
||||
- [ ] Components render correctly
|
||||
- [ ] No infinite loops or excessive re-renders (React DevTools)
|
||||
|
||||
@@ -93,12 +93,14 @@ npm run coverage:view
|
||||
### Pros & Cons
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Native browser coverage (most accurate)
|
||||
- ✅ No build instrumentation needed (faster)
|
||||
- ✅ Works with source maps
|
||||
- ✅ Zero performance overhead
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Chromium only (V8 engine specific)
|
||||
- ❌ Requires v8-to-istanbul conversion
|
||||
|
||||
@@ -168,11 +170,13 @@ This generates: `coverage-combined/index.html`
|
||||
### Pros & Cons
|
||||
|
||||
**Pros:**
|
||||
|
||||
- ✅ Works on all browsers (Firefox, Safari, etc.)
|
||||
- ✅ Industry standard tooling
|
||||
- ✅ No conversion needed
|
||||
|
||||
**Cons:**
|
||||
|
||||
- ❌ Requires code instrumentation (slower builds)
|
||||
- ❌ More complex setup
|
||||
- ❌ Slight test performance overhead
|
||||
@@ -196,11 +200,9 @@ module.exports = {
|
||||
presets: ['next/babel'],
|
||||
env: {
|
||||
test: {
|
||||
plugins: [
|
||||
process.env.E2E_COVERAGE && 'istanbul'
|
||||
].filter(Boolean)
|
||||
}
|
||||
}
|
||||
plugins: [process.env.E2E_COVERAGE && 'istanbul'].filter(Boolean),
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
@@ -442,12 +444,14 @@ Add to your CI pipeline (e.g., `.github/workflows/test.yml`):
|
||||
### Problem: No coverage files generated
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
```bash
|
||||
npm run coverage:convert
|
||||
# ❌ No V8 coverage found at: coverage-e2e/raw
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Verify `E2E_COVERAGE=true` is set when running tests
|
||||
2. Check coverage helpers are imported: `import { withCoverage } from './helpers/coverage'`
|
||||
3. Verify `beforeEach` and `afterEach` hooks are added
|
||||
@@ -456,12 +460,14 @@ npm run coverage:convert
|
||||
### Problem: V8 conversion fails
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
```bash
|
||||
npm run coverage:convert
|
||||
# ❌ v8-to-istanbul not installed
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
npm install -D v8-to-istanbul
|
||||
```
|
||||
@@ -469,6 +475,7 @@ npm install -D v8-to-istanbul
|
||||
### Problem: Coverage lower than expected
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
```
|
||||
Combined: 85% (expected 99%)
|
||||
```
|
||||
@@ -490,12 +497,14 @@ Combined: 85% (expected 99%)
|
||||
### Problem: Istanbul coverage is empty
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
```typescript
|
||||
await saveIstanbulCoverage(page, testName);
|
||||
// ⚠️ No Istanbul coverage found
|
||||
```
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. Verify `babel-plugin-istanbul` is configured
|
||||
2. Check `window.__coverage__` exists:
|
||||
```typescript
|
||||
@@ -507,12 +516,14 @@ await saveIstanbulCoverage(page, testName);
|
||||
### Problem: Merge script fails
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
```bash
|
||||
npm run coverage:merge
|
||||
# ❌ Error: Cannot find module 'istanbul-lib-coverage'
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
npm install -D istanbul-lib-coverage istanbul-lib-report istanbul-reports
|
||||
```
|
||||
@@ -524,11 +535,13 @@ npm install -D istanbul-lib-coverage istanbul-lib-report istanbul-reports
|
||||
### Q: Should I use V8 or Istanbul coverage?
|
||||
|
||||
**A: V8 coverage (Approach 1)** if:
|
||||
|
||||
- ✅ You only test in Chromium
|
||||
- ✅ You want zero instrumentation overhead
|
||||
- ✅ You want the most accurate coverage
|
||||
|
||||
**Istanbul (Approach 2)** if:
|
||||
|
||||
- ✅ You need cross-browser coverage
|
||||
- ✅ You already use Istanbul tooling
|
||||
- ✅ You need complex coverage transformations
|
||||
@@ -545,6 +558,7 @@ npm install -D istanbul-lib-coverage istanbul-lib-report istanbul-reports
|
||||
### Q: Can I run coverage only for specific tests?
|
||||
|
||||
**Yes:**
|
||||
|
||||
```bash
|
||||
# Single file
|
||||
E2E_COVERAGE=true npx playwright test homepage.spec.ts
|
||||
@@ -559,10 +573,7 @@ Edit `.nycrc.json` and add to `exclude` array:
|
||||
|
||||
```json
|
||||
{
|
||||
"exclude": [
|
||||
"src/app/dev/**",
|
||||
"src/lib/utils/debug.ts"
|
||||
]
|
||||
"exclude": ["src/app/dev/**", "src/lib/utils/debug.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -571,6 +582,7 @@ Edit `.nycrc.json` and add to `exclude` array:
|
||||
Not directly in the HTML report, but you can:
|
||||
|
||||
1. Generate separate reports:
|
||||
|
||||
```bash
|
||||
npx nyc report --reporter=html --report-dir=coverage-unit --temp-dir=coverage/.nyc_output
|
||||
npx nyc report --reporter=html --report-dir=coverage-e2e-only --temp-dir=coverage-e2e/.nyc_output
|
||||
@@ -581,6 +593,7 @@ Not directly in the HTML report, but you can:
|
||||
### Q: What's the performance impact on CI?
|
||||
|
||||
Typical impact:
|
||||
|
||||
- V8 coverage: +2-3 minutes (conversion time)
|
||||
- Istanbul coverage: +5-7 minutes (build instrumentation)
|
||||
- Merge step: ~10 seconds
|
||||
@@ -594,6 +607,7 @@ Total CI time increase: **3-8 minutes**
|
||||
### After Phase 1 (Infrastructure - DONE ✅)
|
||||
|
||||
You've completed:
|
||||
|
||||
- ✅ `.nycrc.json` configuration
|
||||
- ✅ Merge script (`scripts/merge-coverage.ts`)
|
||||
- ✅ Conversion script (`scripts/convert-v8-to-istanbul.ts`)
|
||||
@@ -603,6 +617,7 @@ You've completed:
|
||||
### Phase 2: Activation (When Ready)
|
||||
|
||||
1. **Install dependencies:**
|
||||
|
||||
```bash
|
||||
npm install -D v8-to-istanbul istanbul-lib-coverage istanbul-lib-report istanbul-reports
|
||||
```
|
||||
|
||||
@@ -6,20 +6,24 @@
|
||||
## Bottleneck Analysis
|
||||
|
||||
### 1. Authentication Overhead (HIGHEST IMPACT)
|
||||
|
||||
**Problem**: Each test logs in fresh via UI
|
||||
|
||||
- **Impact**: 5-7s per test × 133 admin tests = ~700s wasted
|
||||
- **Root Cause**: Using `loginViaUI(page)` in every `beforeEach`
|
||||
|
||||
**Example of current slow pattern:**
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupSuperuserMocks(page);
|
||||
await loginViaUI(page); // ← 5-7s UI login EVERY test
|
||||
await loginViaUI(page); // ← 5-7s UI login EVERY test
|
||||
await page.goto('/admin');
|
||||
});
|
||||
```
|
||||
|
||||
**Solution: Playwright Storage State** (SAVE ~600-700s)
|
||||
|
||||
```typescript
|
||||
// auth.setup.ts - Run ONCE per worker
|
||||
import { test as setup } from '@playwright/test';
|
||||
@@ -62,7 +66,7 @@ export default defineConfig({
|
||||
// admin-users.spec.ts - NO MORE loginViaUI!
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Auth already loaded from storageState
|
||||
await page.goto('/admin/users'); // ← Direct navigation, ~1-2s
|
||||
await page.goto('/admin/users'); // ← Direct navigation, ~1-2s
|
||||
});
|
||||
```
|
||||
|
||||
@@ -71,28 +75,32 @@ test.beforeEach(async ({ page }) => {
|
||||
---
|
||||
|
||||
### 2. Redundant Navigation Tests (MEDIUM IMPACT)
|
||||
|
||||
**Problem**: Separate tests for "navigate to X" and "display X page"
|
||||
|
||||
- **Impact**: 3-5s per redundant test × ~15 tests = ~60s wasted
|
||||
|
||||
**Current slow pattern:**
|
||||
|
||||
```typescript
|
||||
test('should navigate to users page', async ({ page }) => {
|
||||
await page.goto('/admin/users'); // 3s
|
||||
await page.goto('/admin/users'); // 3s
|
||||
await expect(page).toHaveURL('/admin/users');
|
||||
await expect(page.locator('h1')).toContainText('User Management');
|
||||
});
|
||||
|
||||
test('should display user management page', async ({ page }) => {
|
||||
await page.goto('/admin/users'); // 3s DUPLICATE
|
||||
await page.goto('/admin/users'); // 3s DUPLICATE
|
||||
await expect(page.locator('h1')).toContainText('User Management');
|
||||
await expect(page.getByText(/manage users/i)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**Optimized pattern:**
|
||||
|
||||
```typescript
|
||||
test('should navigate to users page and display content', async ({ page }) => {
|
||||
await page.goto('/admin/users'); // 3s ONCE
|
||||
await page.goto('/admin/users'); // 3s ONCE
|
||||
|
||||
// Navigation assertions
|
||||
await expect(page).toHaveURL('/admin/users');
|
||||
@@ -109,18 +117,22 @@ test('should navigate to users page and display content', async ({ page }) => {
|
||||
---
|
||||
|
||||
### 3. Flaky Test Fix (CRITICAL)
|
||||
|
||||
**Problem**: Test #218 failed once, passed on retry
|
||||
|
||||
```
|
||||
Test: settings-password.spec.ts:24:7 › Password Change › should display password change form
|
||||
Failed: 12.8s → Retry passed: 8.3s
|
||||
```
|
||||
|
||||
**Root Cause Options**:
|
||||
|
||||
1. Race condition in form rendering
|
||||
2. Slow network request not properly awaited
|
||||
3. Animation/transition timing issue
|
||||
|
||||
**Investigation needed:**
|
||||
|
||||
```typescript
|
||||
// Current test (lines 24-35)
|
||||
test('should display password change form', async ({ page }) => {
|
||||
@@ -134,11 +146,12 @@ test('should display password change form', async ({ page }) => {
|
||||
```
|
||||
|
||||
**Temporary Solution: Skip until fixed**
|
||||
|
||||
```typescript
|
||||
test.skip('should display password change form', async ({ page }) => {
|
||||
// TODO: Fix race condition (issue #XXX)
|
||||
await page.goto('/settings/password');
|
||||
await page.waitForLoadState('networkidle'); // ← Add this
|
||||
await page.waitForLoadState('networkidle'); // ← Add this
|
||||
await expect(page.getByLabel(/current password/i)).toBeVisible();
|
||||
});
|
||||
```
|
||||
@@ -148,23 +161,27 @@ test.skip('should display password change form', async ({ page }) => {
|
||||
---
|
||||
|
||||
### 4. Optimize Wait Timeouts (LOW IMPACT)
|
||||
|
||||
**Problem**: Default timeout is 10s for all assertions
|
||||
|
||||
- **Impact**: Tests wait unnecessarily when elements load faster
|
||||
|
||||
**Current global timeout:**
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
timeout: 30000, // Per test
|
||||
expect: { timeout: 10000 }, // Per assertion
|
||||
timeout: 30000, // Per test
|
||||
expect: { timeout: 10000 }, // Per assertion
|
||||
});
|
||||
```
|
||||
|
||||
**Optimized for fast-loading pages:**
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
timeout: 20000, // Reduce from 30s
|
||||
expect: { timeout: 5000 }, // Reduce from 10s (most elements load <2s)
|
||||
timeout: 20000, // Reduce from 30s
|
||||
expect: { timeout: 5000 }, // Reduce from 10s (most elements load <2s)
|
||||
});
|
||||
```
|
||||
|
||||
@@ -175,6 +192,7 @@ export default defineConfig({
|
||||
## Implementation Priority
|
||||
|
||||
### Phase 1: Quick Wins (1-2 hours work)
|
||||
|
||||
1. ✅ **Skip flaky test #218** temporarily
|
||||
2. ✅ **Reduce timeout defaults** (5s for expects, 20s for tests)
|
||||
3. ✅ **Combine 5 most obvious redundant navigation tests**
|
||||
@@ -184,6 +202,7 @@ export default defineConfig({
|
||||
---
|
||||
|
||||
### Phase 2: Auth State Caching (2-4 hours work)
|
||||
|
||||
1. ✅ Create `e2e/auth.setup.ts` with storage state setup
|
||||
2. ✅ Update `playwright.config.ts` with projects + dependencies
|
||||
3. ✅ Remove `loginViaUI` from all admin test `beforeEach` hooks
|
||||
@@ -194,6 +213,7 @@ export default defineConfig({
|
||||
---
|
||||
|
||||
### Phase 3: Deep Optimization (4-8 hours work)
|
||||
|
||||
1. ✅ Investigate and fix flaky test root cause
|
||||
2. ✅ Audit all navigation tests for redundancy
|
||||
3. ✅ Combine related assertions (e.g., all stat cards in one test)
|
||||
@@ -205,12 +225,12 @@ export default defineConfig({
|
||||
|
||||
## Total Expected Improvement
|
||||
|
||||
| Phase | Time Investment | Time Saved | % Improvement |
|
||||
|-------|----------------|------------|---------------|
|
||||
| Phase 1 | 1-2 hours | ~150s | 7% |
|
||||
| Phase 2 | 2-4 hours | ~700s | 35% |
|
||||
| Phase 3 | 4-8 hours | ~200s | 10% |
|
||||
| **Total** | **7-14 hours** | **~1050s** | **50-60%** |
|
||||
| Phase | Time Investment | Time Saved | % Improvement |
|
||||
| --------- | --------------- | ---------- | ------------- |
|
||||
| Phase 1 | 1-2 hours | ~150s | 7% |
|
||||
| Phase 2 | 2-4 hours | ~700s | 35% |
|
||||
| Phase 3 | 4-8 hours | ~200s | 10% |
|
||||
| **Total** | **7-14 hours** | **~1050s** | **50-60%** |
|
||||
|
||||
**Final target**: 2100s → 1050s = **~17-18 minutes** (currently ~35 minutes)
|
||||
|
||||
@@ -219,6 +239,7 @@ export default defineConfig({
|
||||
## Risks and Considerations
|
||||
|
||||
### Storage State Caching Risks:
|
||||
|
||||
1. **Test isolation**: Shared auth state could cause cross-test pollution
|
||||
- **Mitigation**: Use separate storage files per role, clear cookies between tests
|
||||
2. **Stale auth tokens**: Mock tokens might expire
|
||||
@@ -227,6 +248,7 @@ export default defineConfig({
|
||||
- **Mitigation**: Keep `loginViaUI` tests for auth flow verification
|
||||
|
||||
### Recommended Safeguards:
|
||||
|
||||
```typescript
|
||||
// Clear non-auth state between tests
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -249,15 +271,18 @@ test.beforeEach(async ({ page }) => {
|
||||
## Next Steps
|
||||
|
||||
**Immediate Actions (Do Now):**
|
||||
|
||||
1. Skip flaky test #218 with TODO comment
|
||||
2. Reduce timeout defaults in playwright.config.ts
|
||||
3. Create this optimization plan issue/ticket
|
||||
|
||||
**Short-term (This Week):**
|
||||
|
||||
1. Implement auth storage state (Phase 2)
|
||||
2. Combine obvious redundant tests (Phase 1)
|
||||
|
||||
**Medium-term (Next Sprint):**
|
||||
|
||||
1. Investigate flaky test root cause
|
||||
2. Audit all tests for redundancy
|
||||
3. Measure and report improvements
|
||||
@@ -267,18 +292,21 @@ test.beforeEach(async ({ page }) => {
|
||||
## Metrics to Track
|
||||
|
||||
Before optimization:
|
||||
|
||||
- Total time: ~2100s (35 minutes)
|
||||
- Avg test time: 9.1s
|
||||
- Slowest test: 20.1s (settings navigation)
|
||||
- Flaky tests: 1
|
||||
|
||||
After Phase 1+2 target:
|
||||
|
||||
- Total time: <1200s (20 minutes) ✅
|
||||
- Avg test time: <5.5s ✅
|
||||
- Slowest test: <12s ✅
|
||||
- Flaky tests: 0 ✅
|
||||
|
||||
After Phase 3 target:
|
||||
|
||||
- Total time: <1050s (17 minutes) 🎯
|
||||
- Avg test time: <4.8s 🎯
|
||||
- Slowest test: <10s 🎯
|
||||
|
||||
@@ -69,8 +69,7 @@ export function useCreateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateUserDto) =>
|
||||
AdminService.createUser({ requestBody: data }),
|
||||
mutationFn: (data: CreateUserDto) => AdminService.createUser({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
toast.success('User created successfully');
|
||||
@@ -106,8 +105,7 @@ export function useDeleteUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) =>
|
||||
AdminService.deleteUser({ userId }),
|
||||
mutationFn: (userId: string) => AdminService.deleteUser({ userId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
toast.success('User deleted successfully');
|
||||
@@ -145,7 +143,7 @@ export function useBulkUserAction() {
|
||||
return useMutation({
|
||||
mutationFn: ({ action, userIds }: { action: string; userIds: string[] }) =>
|
||||
AdminService.bulkUserAction({
|
||||
requestBody: { action, user_ids: userIds }
|
||||
requestBody: { action, user_ids: userIds },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
@@ -592,6 +590,7 @@ export default function NewUserPage() {
|
||||
### Testing the Feature
|
||||
|
||||
**Test the user management flow:**
|
||||
|
||||
1. Navigate to `/admin/users`
|
||||
2. Search for users
|
||||
3. Click "Create User" and fill the form
|
||||
@@ -630,8 +629,7 @@ export function useRevokeSession() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) =>
|
||||
SessionsService.revokeSession({ sessionId }),
|
||||
mutationFn: (sessionId: string) => SessionsService.revokeSession({ sessionId }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['sessions'] });
|
||||
toast.success('Session revoked successfully');
|
||||
@@ -1041,11 +1039,13 @@ export default function AdminDashboardPage() {
|
||||
## Conclusion
|
||||
|
||||
These examples demonstrate:
|
||||
|
||||
1. **Complete CRUD operations** (User Management)
|
||||
2. **Real-time data with polling** (Session Management)
|
||||
3. **Data visualization** (Admin Dashboard Charts)
|
||||
|
||||
Each example follows the established patterns:
|
||||
|
||||
- API hooks for data fetching
|
||||
- Reusable components
|
||||
- Proper error handling
|
||||
@@ -1053,6 +1053,7 @@ Each example follows the established patterns:
|
||||
- Type safety with TypeScript
|
||||
|
||||
For more patterns and best practices, refer to:
|
||||
|
||||
- **ARCHITECTURE.md**: System design
|
||||
- **CODING_STANDARDS.md**: Code style
|
||||
- **COMPONENT_GUIDE.md**: Component usage
|
||||
|
||||
@@ -66,7 +66,7 @@ import {
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter
|
||||
CardFooter,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
<Card>
|
||||
@@ -80,7 +80,7 @@ import {
|
||||
<CardFooter>
|
||||
<Button>Save</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Card>;
|
||||
```
|
||||
|
||||
**[See card examples](/dev/components#card)**
|
||||
@@ -95,18 +95,9 @@ import { Input } from '@/components/ui/input';
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Input id="email" type="email" placeholder="you@example.com" {...register('email')} />
|
||||
{errors.email && <p className="text-sm text-destructive">{errors.email.message}</p>}
|
||||
</div>;
|
||||
```
|
||||
|
||||
**[See form patterns](./06-forms.md)** | **[Form examples](/dev/forms)**
|
||||
@@ -123,7 +114,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTrigger
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
<Dialog>
|
||||
@@ -133,16 +124,14 @@ import {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Action</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to proceed?
|
||||
</DialogDescription>
|
||||
<DialogDescription>Are you sure you want to proceed?</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button>Confirm</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>;
|
||||
```
|
||||
|
||||
**[See dialog examples](/dev/components#dialog)**
|
||||
@@ -197,7 +186,7 @@ import { AlertCircle } from 'lucide-react';
|
||||
```tsx
|
||||
// Responsive card grid
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{items.map(item => (
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{item.title}</CardTitle>
|
||||
@@ -218,9 +207,7 @@ import { AlertCircle } from 'lucide-react';
|
||||
<CardTitle>Login</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4">
|
||||
{/* Form fields */}
|
||||
</form>
|
||||
<form className="space-y-4">{/* Form fields */}</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -247,6 +234,7 @@ import { AlertCircle } from 'lucide-react';
|
||||
```
|
||||
|
||||
**Available tokens:**
|
||||
|
||||
- `primary` - Main brand color, CTAs
|
||||
- `destructive` - Errors, delete actions
|
||||
- `muted` - Disabled states, subtle backgrounds
|
||||
@@ -276,6 +264,7 @@ import { AlertCircle } from 'lucide-react';
|
||||
```
|
||||
|
||||
**Common spacing values:**
|
||||
|
||||
- `2` (8px) - Tight spacing
|
||||
- `4` (16px) - Standard spacing
|
||||
- `6` (24px) - Section spacing
|
||||
@@ -326,6 +315,7 @@ import { AlertCircle } from 'lucide-react';
|
||||
```
|
||||
|
||||
**Breakpoints:**
|
||||
|
||||
- `sm:` 640px+
|
||||
- `md:` 768px+
|
||||
- `lg:` 1024px+
|
||||
@@ -370,11 +360,9 @@ import { AlertCircle } from 'lucide-react';
|
||||
```tsx
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-12 w-full" />
|
||||
) : (
|
||||
<div>{content}</div>
|
||||
)}
|
||||
{
|
||||
isLoading ? <Skeleton className="h-12 w-full" /> : <div>{content}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Dropdown Menu
|
||||
@@ -384,7 +372,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
<DropdownMenu>
|
||||
@@ -395,7 +383,7 @@ import {
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenu>;
|
||||
```
|
||||
|
||||
### Badge/Tag
|
||||
@@ -415,17 +403,20 @@ import { Badge } from '@/components/ui/badge';
|
||||
You now know enough to build most interfaces! For deeper knowledge:
|
||||
|
||||
### Learn More
|
||||
|
||||
- **Components**: [Complete component guide](./02-components.md)
|
||||
- **Layouts**: [Layout patterns](./03-layouts.md)
|
||||
- **Forms**: [Form patterns & validation](./06-forms.md)
|
||||
- **Custom Components**: [Component creation guide](./05-component-creation.md)
|
||||
|
||||
### Interactive Examples
|
||||
|
||||
- **[Component Showcase](/dev/components)** - All components with code
|
||||
- **[Layout Examples](/dev/layouts)** - Before/after comparisons
|
||||
- **[Form Examples](/dev/forms)** - Complete form implementations
|
||||
|
||||
### Reference
|
||||
|
||||
- **[Quick Reference Tables](./99-reference.md)** - Bookmark this for lookups
|
||||
- **[Foundations](./01-foundations.md)** - Complete color/spacing/typography guide
|
||||
|
||||
@@ -449,6 +440,7 @@ Remember these and you'll be 95% compliant:
|
||||
You're ready to build. When you hit edge cases or need advanced patterns, refer back to the [full documentation](./README.md).
|
||||
|
||||
**Bookmark these:**
|
||||
|
||||
- [Quick Reference](./99-reference.md) - For quick lookups
|
||||
- [AI Guidelines](./08-ai-guidelines.md) - If using AI assistants
|
||||
- [Component Showcase](/dev/components) - For copy-paste examples
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
### Why OKLCH?
|
||||
|
||||
We use **OKLCH** (Oklab LCH) color space for:
|
||||
|
||||
- ✅ **Perceptual uniformity** - Colors look consistent across light/dark modes
|
||||
- ✅ **Better accessibility** - Predictable contrast ratios
|
||||
- ✅ **Vibrant colors** - More saturated without sacrificing legibility
|
||||
@@ -55,6 +56,7 @@ We use **OKLCH** (Oklab LCH) color space for:
|
||||
### Semantic Color Tokens
|
||||
|
||||
All colors follow the **background/foreground** convention:
|
||||
|
||||
- `background` - The background color
|
||||
- `foreground` - The text color that goes on that background
|
||||
|
||||
@@ -68,11 +70,12 @@ All colors follow the **background/foreground** convention:
|
||||
|
||||
```css
|
||||
/* Light & Dark Mode */
|
||||
--primary: oklch(0.6231 0.1880 259.8145) /* Blue */
|
||||
--primary-foreground: oklch(1 0 0) /* White text */
|
||||
--primary: oklch(0.6231 0.188 259.8145) /* Blue */ --primary-foreground: oklch(1 0 0)
|
||||
/* White text */;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Primary button (most common)
|
||||
<Button>Save Changes</Button>
|
||||
@@ -87,12 +90,14 @@ All colors follow the **background/foreground** convention:
|
||||
```
|
||||
|
||||
**When to use**:
|
||||
|
||||
- ✅ Call-to-action buttons
|
||||
- ✅ Primary links
|
||||
- ✅ Active states in navigation
|
||||
- ✅ Important badges/tags
|
||||
|
||||
**When NOT to use**:
|
||||
|
||||
- ❌ Large background areas (too intense)
|
||||
- ❌ Body text (use `text-foreground`)
|
||||
- ❌ Disabled states (use `muted`)
|
||||
@@ -105,15 +110,14 @@ All colors follow the **background/foreground** convention:
|
||||
|
||||
```css
|
||||
/* Light Mode */
|
||||
--secondary: oklch(0.9670 0.0029 264.5419) /* Light gray-blue */
|
||||
--secondary-foreground: oklch(0.1529 0 0) /* Dark text */
|
||||
|
||||
/* Dark Mode */
|
||||
--secondary: oklch(0.2686 0 0) /* Dark gray */
|
||||
--secondary-foreground: oklch(0.9823 0 0) /* Light text */
|
||||
--secondary: oklch(0.967 0.0029 264.5419) /* Light gray-blue */
|
||||
--secondary-foreground: oklch(0.1529 0 0) /* Dark text */ /* Dark Mode */
|
||||
--secondary: oklch(0.2686 0 0) /* Dark gray */ --secondary-foreground: oklch(0.9823 0 0)
|
||||
/* Light text */;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Secondary button
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
@@ -135,15 +139,12 @@ All colors follow the **background/foreground** convention:
|
||||
|
||||
```css
|
||||
/* Light Mode */
|
||||
--muted: oklch(0.9846 0.0017 247.8389)
|
||||
--muted-foreground: oklch(0.4667 0.0043 264.4327)
|
||||
|
||||
/* Dark Mode */
|
||||
--muted: oklch(0.2393 0 0)
|
||||
--muted-foreground: oklch(0.6588 0.0043 264.4327)
|
||||
--muted: oklch(0.9846 0.0017 247.8389) --muted-foreground: oklch(0.4667 0.0043 264.4327)
|
||||
/* Dark Mode */ --muted: oklch(0.2393 0 0) --muted-foreground: oklch(0.6588 0.0043 264.4327);
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Disabled button
|
||||
<Button disabled>Submit</Button>
|
||||
@@ -165,6 +166,7 @@ All colors follow the **background/foreground** convention:
|
||||
```
|
||||
|
||||
**Common use cases**:
|
||||
|
||||
- Disabled button backgrounds
|
||||
- Placeholder/skeleton loaders
|
||||
- TabsList backgrounds
|
||||
@@ -179,15 +181,12 @@ All colors follow the **background/foreground** convention:
|
||||
|
||||
```css
|
||||
/* Light Mode */
|
||||
--accent: oklch(0.9514 0.0250 236.8242)
|
||||
--accent-foreground: oklch(0.1529 0 0)
|
||||
|
||||
/* Dark Mode */
|
||||
--accent: oklch(0.3791 0.1378 265.5222)
|
||||
--accent-foreground: oklch(0.9823 0 0)
|
||||
--accent: oklch(0.9514 0.025 236.8242) --accent-foreground: oklch(0.1529 0 0) /* Dark Mode */
|
||||
--accent: oklch(0.3791 0.1378 265.5222) --accent-foreground: oklch(0.9823 0 0);
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Dropdown menu item hover
|
||||
<DropdownMenu>
|
||||
@@ -205,6 +204,7 @@ All colors follow the **background/foreground** convention:
|
||||
```
|
||||
|
||||
**Common use cases**:
|
||||
|
||||
- Dropdown menu item hover states
|
||||
- Command palette hover states
|
||||
- Highlighted sections
|
||||
@@ -218,11 +218,12 @@ All colors follow the **background/foreground** convention:
|
||||
|
||||
```css
|
||||
/* Light & Dark Mode */
|
||||
--destructive: oklch(0.6368 0.2078 25.3313) /* Red */
|
||||
--destructive-foreground: oklch(1 0 0) /* White text */
|
||||
--destructive: oklch(0.6368 0.2078 25.3313) /* Red */ --destructive-foreground: oklch(1 0 0)
|
||||
/* White text */;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Delete button
|
||||
<Button variant="destructive">Delete Account</Button>
|
||||
@@ -246,6 +247,7 @@ All colors follow the **background/foreground** convention:
|
||||
```
|
||||
|
||||
**When to use**:
|
||||
|
||||
- ✅ Delete/remove actions
|
||||
- ✅ Error messages
|
||||
- ✅ Validation errors
|
||||
@@ -259,19 +261,15 @@ All colors follow the **background/foreground** convention:
|
||||
|
||||
```css
|
||||
/* Light Mode */
|
||||
--card: oklch(1.0000 0 0) /* White */
|
||||
--card-foreground: oklch(0.1529 0 0) /* Dark text */
|
||||
--popover: oklch(1.0000 0 0) /* White */
|
||||
--popover-foreground: oklch(0.1529 0 0) /* Dark text */
|
||||
|
||||
/* Dark Mode */
|
||||
--card: oklch(0.2686 0 0) /* Dark gray */
|
||||
--card-foreground: oklch(0.9823 0 0) /* Light text */
|
||||
--popover: oklch(0.2686 0 0) /* Dark gray */
|
||||
--popover-foreground: oklch(0.9823 0 0) /* Light text */
|
||||
--card: oklch(1 0 0) /* White */ --card-foreground: oklch(0.1529 0 0) /* Dark text */
|
||||
--popover: oklch(1 0 0) /* White */ --popover-foreground: oklch(0.1529 0 0) /* Dark text */
|
||||
/* Dark Mode */ --card: oklch(0.2686 0 0) /* Dark gray */ --card-foreground: oklch(0.9823 0 0)
|
||||
/* Light text */ --popover: oklch(0.2686 0 0) /* Dark gray */
|
||||
--popover-foreground: oklch(0.9823 0 0) /* Light text */;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Card (uses card colors by default)
|
||||
<Card>
|
||||
@@ -296,15 +294,12 @@ All colors follow the **background/foreground** convention:
|
||||
|
||||
```css
|
||||
/* Light Mode */
|
||||
--border: oklch(0.9276 0.0058 264.5313)
|
||||
--input: oklch(0.9276 0.0058 264.5313)
|
||||
|
||||
/* Dark Mode */
|
||||
--border: oklch(0.3715 0 0)
|
||||
--input: oklch(0.3715 0 0)
|
||||
--border: oklch(0.9276 0.0058 264.5313) --input: oklch(0.9276 0.0058 264.5313) /* Dark Mode */
|
||||
--border: oklch(0.3715 0 0) --input: oklch(0.3715 0 0);
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Input border
|
||||
<Input type="email" placeholder="you@example.com" />
|
||||
@@ -329,10 +324,11 @@ All colors follow the **background/foreground** convention:
|
||||
|
||||
```css
|
||||
/* Light & Dark Mode */
|
||||
--ring: oklch(0.6231 0.1880 259.8145) /* Primary blue */
|
||||
--ring: oklch(0.6231 0.188 259.8145) /* Primary blue */;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Button with focus ring (automatic)
|
||||
<Button>Click me</Button>
|
||||
@@ -355,14 +351,14 @@ All colors follow the **background/foreground** convention:
|
||||
**Purpose**: Data visualization with harmonious color palette
|
||||
|
||||
```css
|
||||
--chart-1: oklch(0.6231 0.1880 259.8145) /* Blue */
|
||||
--chart-2: oklch(0.5461 0.2152 262.8809) /* Purple-blue */
|
||||
--chart-3: oklch(0.4882 0.2172 264.3763) /* Deep purple */
|
||||
--chart-4: oklch(0.4244 0.1809 265.6377) /* Violet */
|
||||
--chart-5: oklch(0.3791 0.1378 265.5222) /* Deep violet */
|
||||
--chart-1: oklch(0.6231 0.188 259.8145) /* Blue */ --chart-2: oklch(0.5461 0.2152 262.8809)
|
||||
/* Purple-blue */ --chart-3: oklch(0.4882 0.2172 264.3763) /* Deep purple */
|
||||
--chart-4: oklch(0.4244 0.1809 265.6377) /* Violet */ --chart-5: oklch(0.3791 0.1378 265.5222)
|
||||
/* Deep violet */;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// In chart components
|
||||
const COLORS = [
|
||||
@@ -436,12 +432,13 @@ What's the purpose?
|
||||
### Font Families
|
||||
|
||||
```css
|
||||
--font-sans: Geist Sans, system-ui, -apple-system, sans-serif
|
||||
--font-mono: Geist Mono, ui-monospace, monospace
|
||||
--font-serif: ui-serif, Georgia, serif
|
||||
--font-sans:
|
||||
Geist Sans, system-ui, -apple-system, sans-serif --font-mono: Geist Mono, ui-monospace,
|
||||
monospace --font-serif: ui-serif, Georgia, serif;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Sans serif (default)
|
||||
<div className="font-sans">Body text</div>
|
||||
@@ -457,21 +454,21 @@ What's the purpose?
|
||||
|
||||
### Type Scale
|
||||
|
||||
| Size | Class | rem | px | Use Case |
|
||||
|------|-------|-----|----|----|
|
||||
| 9xl | `text-9xl` | 8rem | 128px | Hero text (rare) |
|
||||
| 8xl | `text-8xl` | 6rem | 96px | Hero text (rare) |
|
||||
| 7xl | `text-7xl` | 4.5rem | 72px | Hero text (rare) |
|
||||
| 6xl | `text-6xl` | 3.75rem | 60px | Hero text (rare) |
|
||||
| 5xl | `text-5xl` | 3rem | 48px | Landing page H1 |
|
||||
| 4xl | `text-4xl` | 2.25rem | 36px | Page H1 |
|
||||
| 3xl | `text-3xl` | 1.875rem | 30px | **Page titles** |
|
||||
| 2xl | `text-2xl` | 1.5rem | 24px | **Section headings** |
|
||||
| xl | `text-xl` | 1.25rem | 20px | **Card titles** |
|
||||
| lg | `text-lg` | 1.125rem | 18px | **Subheadings** |
|
||||
| base | `text-base` | 1rem | 16px | **Body text (default)** |
|
||||
| sm | `text-sm` | 0.875rem | 14px | **Secondary text, captions** |
|
||||
| xs | `text-xs` | 0.75rem | 12px | **Labels, helper text** |
|
||||
| Size | Class | rem | px | Use Case |
|
||||
| ---- | ----------- | -------- | ----- | ---------------------------- |
|
||||
| 9xl | `text-9xl` | 8rem | 128px | Hero text (rare) |
|
||||
| 8xl | `text-8xl` | 6rem | 96px | Hero text (rare) |
|
||||
| 7xl | `text-7xl` | 4.5rem | 72px | Hero text (rare) |
|
||||
| 6xl | `text-6xl` | 3.75rem | 60px | Hero text (rare) |
|
||||
| 5xl | `text-5xl` | 3rem | 48px | Landing page H1 |
|
||||
| 4xl | `text-4xl` | 2.25rem | 36px | Page H1 |
|
||||
| 3xl | `text-3xl` | 1.875rem | 30px | **Page titles** |
|
||||
| 2xl | `text-2xl` | 1.5rem | 24px | **Section headings** |
|
||||
| xl | `text-xl` | 1.25rem | 20px | **Card titles** |
|
||||
| lg | `text-lg` | 1.125rem | 18px | **Subheadings** |
|
||||
| base | `text-base` | 1rem | 16px | **Body text (default)** |
|
||||
| sm | `text-sm` | 0.875rem | 14px | **Secondary text, captions** |
|
||||
| xs | `text-xs` | 0.75rem | 12px | **Labels, helper text** |
|
||||
|
||||
**Bold = most commonly used**
|
||||
|
||||
@@ -479,13 +476,13 @@ What's the purpose?
|
||||
|
||||
### Font Weights
|
||||
|
||||
| Weight | Class | Numeric | Use Case |
|
||||
|--------|-------|---------|----------|
|
||||
| Bold | `font-bold` | 700 | **Headings, emphasis** |
|
||||
| Semibold | `font-semibold` | 600 | **Subheadings, buttons** |
|
||||
| Medium | `font-medium` | 500 | **Labels, menu items** |
|
||||
| Normal | `font-normal` | 400 | **Body text (default)** |
|
||||
| Light | `font-light` | 300 | De-emphasized text |
|
||||
| Weight | Class | Numeric | Use Case |
|
||||
| -------- | --------------- | ------- | ------------------------ |
|
||||
| Bold | `font-bold` | 700 | **Headings, emphasis** |
|
||||
| Semibold | `font-semibold` | 600 | **Subheadings, buttons** |
|
||||
| Medium | `font-medium` | 500 | **Labels, menu items** |
|
||||
| Normal | `font-normal` | 400 | **Body text (default)** |
|
||||
| Light | `font-light` | 300 | De-emphasized text |
|
||||
|
||||
**Bold = most commonly used**
|
||||
|
||||
@@ -494,35 +491,37 @@ What's the purpose?
|
||||
### Typography Patterns
|
||||
|
||||
#### Page Title
|
||||
|
||||
```tsx
|
||||
<h1 className="text-3xl font-bold">Page Title</h1>
|
||||
```
|
||||
|
||||
#### Section Heading
|
||||
|
||||
```tsx
|
||||
<h2 className="text-2xl font-semibold mb-4">Section Heading</h2>
|
||||
```
|
||||
|
||||
#### Card Title
|
||||
|
||||
```tsx
|
||||
<CardTitle className="text-xl font-semibold">Card Title</CardTitle>
|
||||
```
|
||||
|
||||
#### Body Text
|
||||
|
||||
```tsx
|
||||
<p className="text-base text-foreground">
|
||||
Regular paragraph text uses the default text-base size.
|
||||
</p>
|
||||
<p className="text-base text-foreground">Regular paragraph text uses the default text-base size.</p>
|
||||
```
|
||||
|
||||
#### Secondary Text
|
||||
|
||||
```tsx
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Helper text, timestamps, captions
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Helper text, timestamps, captions</p>
|
||||
```
|
||||
|
||||
#### Label
|
||||
|
||||
```tsx
|
||||
<Label htmlFor="email" className="text-sm font-medium">
|
||||
Email Address
|
||||
@@ -533,16 +532,17 @@ What's the purpose?
|
||||
|
||||
### Line Height
|
||||
|
||||
| Class | Value | Use Case |
|
||||
|-------|-------|----------|
|
||||
| `leading-none` | 1 | Headings (rare) |
|
||||
| `leading-tight` | 1.25 | **Headings** |
|
||||
| `leading-snug` | 1.375 | Dense text |
|
||||
| `leading-normal` | 1.5 | **Body text (default)** |
|
||||
| `leading-relaxed` | 1.625 | Comfortable reading |
|
||||
| `leading-loose` | 2 | Very relaxed (rare) |
|
||||
| Class | Value | Use Case |
|
||||
| ----------------- | ----- | ----------------------- |
|
||||
| `leading-none` | 1 | Headings (rare) |
|
||||
| `leading-tight` | 1.25 | **Headings** |
|
||||
| `leading-snug` | 1.375 | Dense text |
|
||||
| `leading-normal` | 1.5 | **Body text (default)** |
|
||||
| `leading-relaxed` | 1.625 | Comfortable reading |
|
||||
| `leading-loose` | 2 | Very relaxed (rare) |
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Heading
|
||||
<h1 className="text-3xl font-bold leading-tight">
|
||||
@@ -622,23 +622,23 @@ Tailwind uses a **0.25rem (4px) base unit**:
|
||||
|
||||
### Spacing Tokens
|
||||
|
||||
| Token | rem | Pixels | Use Case |
|
||||
|-------|-----|--------|----------|
|
||||
| `0` | 0 | 0px | No spacing |
|
||||
| `px` | - | 1px | Borders, dividers |
|
||||
| `0.5` | 0.125rem | 2px | Very tight |
|
||||
| `1` | 0.25rem | 4px | Icon gaps |
|
||||
| `2` | 0.5rem | 8px | **Tight spacing** (label → input) |
|
||||
| `3` | 0.75rem | 12px | Component padding |
|
||||
| `4` | 1rem | 16px | **Standard spacing** (form fields) |
|
||||
| `5` | 1.25rem | 20px | Medium spacing |
|
||||
| `6` | 1.5rem | 24px | **Section spacing** (cards) |
|
||||
| `8` | 2rem | 32px | **Large gaps** |
|
||||
| `10` | 2.5rem | 40px | Very large gaps |
|
||||
| `12` | 3rem | 48px | **Section dividers** |
|
||||
| `16` | 4rem | 64px | **Page sections** |
|
||||
| `20` | 5rem | 80px | Extra large |
|
||||
| `24` | 6rem | 96px | Huge spacing |
|
||||
| Token | rem | Pixels | Use Case |
|
||||
| ----- | -------- | ------ | ---------------------------------- |
|
||||
| `0` | 0 | 0px | No spacing |
|
||||
| `px` | - | 1px | Borders, dividers |
|
||||
| `0.5` | 0.125rem | 2px | Very tight |
|
||||
| `1` | 0.25rem | 4px | Icon gaps |
|
||||
| `2` | 0.5rem | 8px | **Tight spacing** (label → input) |
|
||||
| `3` | 0.75rem | 12px | Component padding |
|
||||
| `4` | 1rem | 16px | **Standard spacing** (form fields) |
|
||||
| `5` | 1.25rem | 20px | Medium spacing |
|
||||
| `6` | 1.5rem | 24px | **Section spacing** (cards) |
|
||||
| `8` | 2rem | 32px | **Large gaps** |
|
||||
| `10` | 2.5rem | 40px | Very large gaps |
|
||||
| `12` | 3rem | 48px | **Section dividers** |
|
||||
| `16` | 4rem | 64px | **Page sections** |
|
||||
| `20` | 5rem | 80px | Extra large |
|
||||
| `24` | 6rem | 96px | Huge spacing |
|
||||
|
||||
**Bold = most commonly used**
|
||||
|
||||
@@ -660,18 +660,18 @@ Tailwind uses a **0.25rem (4px) base unit**:
|
||||
|
||||
### Max Width Scale
|
||||
|
||||
| Class | Pixels | Use Case |
|
||||
|-------|--------|----------|
|
||||
| `max-w-xs` | 320px | Tiny cards |
|
||||
| `max-w-sm` | 384px | Small cards |
|
||||
| `max-w-md` | 448px | **Forms** |
|
||||
| `max-w-lg` | 512px | **Modals** |
|
||||
| `max-w-xl` | 576px | Medium content |
|
||||
| `max-w-2xl` | 672px | **Article content** |
|
||||
| `max-w-3xl` | 768px | Documentation |
|
||||
| `max-w-4xl` | 896px | **Wide layouts** |
|
||||
| `max-w-5xl` | 1024px | Extra wide |
|
||||
| `max-w-6xl` | 1152px | Very wide |
|
||||
| Class | Pixels | Use Case |
|
||||
| ----------- | ------ | ------------------- |
|
||||
| `max-w-xs` | 320px | Tiny cards |
|
||||
| `max-w-sm` | 384px | Small cards |
|
||||
| `max-w-md` | 448px | **Forms** |
|
||||
| `max-w-lg` | 512px | **Modals** |
|
||||
| `max-w-xl` | 576px | Medium content |
|
||||
| `max-w-2xl` | 672px | **Article content** |
|
||||
| `max-w-3xl` | 768px | Documentation |
|
||||
| `max-w-4xl` | 896px | **Wide layouts** |
|
||||
| `max-w-5xl` | 1024px | Extra wide |
|
||||
| `max-w-6xl` | 1152px | Very wide |
|
||||
| `max-w-7xl` | 1280px | **Full page width** |
|
||||
|
||||
**Bold = most commonly used**
|
||||
@@ -729,27 +729,28 @@ Tailwind uses a **0.25rem (4px) base unit**:
|
||||
Professional shadow system for depth and elevation:
|
||||
|
||||
```css
|
||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05)
|
||||
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10)
|
||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10)
|
||||
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10)
|
||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10)
|
||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10)
|
||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25)
|
||||
--shadow-xs:
|
||||
0 1px 3px 0px hsl(0 0% 0% / 0.05) --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 1px 2px -1px hsl(0 0% 0% / 0.1) --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 1px 2px -1px hsl(0 0% 0% / 0.1) --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 2px 4px -1px hsl(0 0% 0% / 0.1) --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 4px 6px -1px hsl(0 0% 0% / 0.1) --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1),
|
||||
0 8px 10px -1px hsl(0 0% 0% / 0.1) --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||
```
|
||||
|
||||
### Shadow Usage
|
||||
|
||||
| Elevation | Class | Use Case |
|
||||
|-----------|-------|----------|
|
||||
| Base | No shadow | Buttons, inline elements |
|
||||
| Low | `shadow-sm` | **Cards, panels** |
|
||||
| Medium | `shadow-md` | **Dropdowns, tooltips** |
|
||||
| High | `shadow-lg` | **Modals, popovers** |
|
||||
| Highest | `shadow-xl` | Notifications, floating elements |
|
||||
| Maximum | `shadow-2xl` | Dialogs (rare) |
|
||||
| Elevation | Class | Use Case |
|
||||
| --------- | ------------ | -------------------------------- |
|
||||
| Base | No shadow | Buttons, inline elements |
|
||||
| Low | `shadow-sm` | **Cards, panels** |
|
||||
| Medium | `shadow-md` | **Dropdowns, tooltips** |
|
||||
| High | `shadow-lg` | **Modals, popovers** |
|
||||
| Highest | `shadow-xl` | Notifications, floating elements |
|
||||
| Maximum | `shadow-2xl` | Dialogs (rare) |
|
||||
|
||||
**Usage**:
|
||||
|
||||
```tsx
|
||||
// Card with subtle shadow
|
||||
<Card className="shadow-sm">Card content</Card>
|
||||
@@ -779,26 +780,24 @@ Professional shadow system for depth and elevation:
|
||||
Consistent rounded corners across the application:
|
||||
|
||||
```css
|
||||
--radius: 0.375rem; /* 6px - base */
|
||||
--radius: 0.375rem; /* 6px - base */
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px) /* 2px */
|
||||
--radius-md: calc(var(--radius) - 2px) /* 4px */
|
||||
--radius-lg: var(--radius) /* 6px */
|
||||
--radius-xl: calc(var(--radius) + 4px) /* 10px */
|
||||
--radius-sm: calc(var(--radius) - 4px) /* 2px */ --radius-md: calc(var(--radius) - 2px) /* 4px */
|
||||
--radius-lg: var(--radius) /* 6px */ --radius-xl: calc(var(--radius) + 4px) /* 10px */;
|
||||
```
|
||||
|
||||
### Border Radius Scale
|
||||
|
||||
| Token | Class | Pixels | Use Case |
|
||||
|-------|-------|--------|----------|
|
||||
| None | `rounded-none` | 0px | Square elements |
|
||||
| Small | `rounded-sm` | 2px | **Tags, small badges** |
|
||||
| Medium | `rounded-md` | 4px | **Inputs, small buttons** |
|
||||
| Large | `rounded-lg` | 6px | **Cards, buttons (default)** |
|
||||
| XL | `rounded-xl` | 10px | **Large cards, modals** |
|
||||
| 2XL | `rounded-2xl` | 16px | Hero sections |
|
||||
| 3XL | `rounded-3xl` | 24px | Very rounded |
|
||||
| Full | `rounded-full` | 9999px | **Pills, avatars, icon buttons** |
|
||||
| Token | Class | Pixels | Use Case |
|
||||
| ------ | -------------- | ------ | -------------------------------- |
|
||||
| None | `rounded-none` | 0px | Square elements |
|
||||
| Small | `rounded-sm` | 2px | **Tags, small badges** |
|
||||
| Medium | `rounded-md` | 4px | **Inputs, small buttons** |
|
||||
| Large | `rounded-lg` | 6px | **Cards, buttons (default)** |
|
||||
| XL | `rounded-xl` | 10px | **Large cards, modals** |
|
||||
| 2XL | `rounded-2xl` | 16px | Hero sections |
|
||||
| 3XL | `rounded-3xl` | 24px | Very rounded |
|
||||
| Full | `rounded-full` | 9999px | **Pills, avatars, icon buttons** |
|
||||
|
||||
**Bold = most commonly used**
|
||||
|
||||
@@ -854,6 +853,7 @@ Consistent rounded corners across the application:
|
||||
### Most Used Tokens
|
||||
|
||||
**Colors**:
|
||||
|
||||
- `bg-primary text-primary-foreground` - CTAs
|
||||
- `bg-destructive text-destructive-foreground` - Delete/errors
|
||||
- `bg-muted text-muted-foreground` - Disabled/subtle
|
||||
@@ -862,6 +862,7 @@ Consistent rounded corners across the application:
|
||||
- `border-border` - Borders
|
||||
|
||||
**Typography**:
|
||||
|
||||
- `text-3xl font-bold` - Page titles
|
||||
- `text-2xl font-semibold` - Section headings
|
||||
- `text-xl font-semibold` - Card titles
|
||||
@@ -869,6 +870,7 @@ Consistent rounded corners across the application:
|
||||
- `text-sm text-muted-foreground` - Secondary text
|
||||
|
||||
**Spacing**:
|
||||
|
||||
- `p-4` - Standard padding (16px)
|
||||
- `p-6` - Card padding (24px)
|
||||
- `gap-4` - Standard gap (16px)
|
||||
@@ -877,6 +879,7 @@ Consistent rounded corners across the application:
|
||||
- `space-y-6` - Section spacing (24px)
|
||||
|
||||
**Shadows & Radius**:
|
||||
|
||||
- `shadow-sm` - Cards
|
||||
- `shadow-md` - Dropdowns
|
||||
- `shadow-lg` - Modals
|
||||
@@ -896,12 +899,14 @@ Consistent rounded corners across the application:
|
||||
---
|
||||
|
||||
**Related Documentation:**
|
||||
|
||||
- [Quick Start](./00-quick-start.md) - Essential patterns
|
||||
- [Components](./02-components.md) - shadcn/ui library
|
||||
- [Spacing Philosophy](./04-spacing-philosophy.md) - Margin vs padding strategy
|
||||
- [Accessibility](./07-accessibility.md) - WCAG compliance
|
||||
|
||||
**External Resources:**
|
||||
|
||||
- [OKLCH Color Picker](https://oklch.com)
|
||||
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
|
||||
- [Tailwind CSS Documentation](https://tailwindcss.com/docs)
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
We use **[shadcn/ui](https://ui.shadcn.com)**, a collection of accessible, customizable components built on **Radix UI primitives**.
|
||||
|
||||
**Key features:**
|
||||
|
||||
- ✅ **Accessible** - WCAG AA compliant, keyboard navigation, screen reader support
|
||||
- ✅ **Customizable** - Components are copied into your project (not npm dependencies)
|
||||
- ✅ **Composable** - Build complex UIs from simple primitives
|
||||
@@ -41,6 +42,7 @@ npx shadcn@latest add
|
||||
```
|
||||
|
||||
**Installed components** (in `/src/components/ui/`):
|
||||
|
||||
- alert, avatar, badge, button, card, checkbox, dialog
|
||||
- dropdown-menu, input, label, popover, select, separator
|
||||
- sheet, skeleton, table, tabs, textarea, toast
|
||||
@@ -82,16 +84,17 @@ import { Button } from '@/components/ui/button';
|
||||
|
||||
**When to use each variant:**
|
||||
|
||||
| Variant | Use Case | Example |
|
||||
|---------|----------|---------|
|
||||
| `default` | Primary actions, CTAs | Save, Submit, Create |
|
||||
| `secondary` | Secondary actions | Cancel, Back |
|
||||
| `outline` | Alternative actions | View Details, Edit |
|
||||
| `ghost` | Subtle actions in lists | Icon buttons in table rows |
|
||||
| `link` | In-text actions | Read more, Learn more |
|
||||
| `destructive` | Delete, remove actions | Delete Account, Remove |
|
||||
| Variant | Use Case | Example |
|
||||
| ------------- | ----------------------- | -------------------------- |
|
||||
| `default` | Primary actions, CTAs | Save, Submit, Create |
|
||||
| `secondary` | Secondary actions | Cancel, Back |
|
||||
| `outline` | Alternative actions | View Details, Edit |
|
||||
| `ghost` | Subtle actions in lists | Icon buttons in table rows |
|
||||
| `link` | In-text actions | Read more, Learn more |
|
||||
| `destructive` | Delete, remove actions | Delete Account, Remove |
|
||||
|
||||
**Accessibility**:
|
||||
|
||||
- Always add `aria-label` for icon-only buttons
|
||||
- Use `disabled` for unavailable actions (not hidden)
|
||||
- Loading state prevents double-submission
|
||||
@@ -162,6 +165,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
```
|
||||
|
||||
**Pattern: User menu**:
|
||||
|
||||
```tsx
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
@@ -323,6 +327,7 @@ import { Label } from '@/components/ui/label';
|
||||
```
|
||||
|
||||
**Input types:**
|
||||
|
||||
- `text` - Default text input
|
||||
- `email` - Email address
|
||||
- `password` - Password field
|
||||
@@ -530,6 +535,7 @@ import { AlertCircle, CheckCircle, Info } from 'lucide-react';
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
|
||||
- ✅ Form-level errors
|
||||
- ✅ Important warnings
|
||||
- ✅ Success confirmations (inline)
|
||||
@@ -557,14 +563,11 @@ toast.info('Processing your request...');
|
||||
toast.warning('This action cannot be undone');
|
||||
|
||||
// Loading (with promise)
|
||||
toast.promise(
|
||||
saveChanges(),
|
||||
{
|
||||
loading: 'Saving changes...',
|
||||
success: 'Changes saved!',
|
||||
error: 'Failed to save changes',
|
||||
}
|
||||
);
|
||||
toast.promise(saveChanges(), {
|
||||
loading: 'Saving changes...',
|
||||
success: 'Changes saved!',
|
||||
error: 'Failed to save changes',
|
||||
});
|
||||
|
||||
// Custom with action
|
||||
toast('Event has been created', {
|
||||
@@ -580,6 +583,7 @@ toast.dismiss();
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
|
||||
- ✅ Action confirmations (saved, deleted)
|
||||
- ✅ Background task updates
|
||||
- ✅ Temporary errors
|
||||
@@ -629,12 +633,11 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
```
|
||||
|
||||
**Pattern: Loading states**:
|
||||
|
||||
```tsx
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-48 w-full" />
|
||||
) : (
|
||||
<div>{content}</div>
|
||||
)}
|
||||
{
|
||||
isLoading ? <Skeleton className="h-48 w-full" /> : <div>{content}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -654,7 +657,7 @@ import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
DialogClose
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
// Basic dialog
|
||||
@@ -678,7 +681,7 @@ import {
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>;
|
||||
|
||||
// Controlled dialog
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -695,10 +698,11 @@ const [isOpen, setIsOpen] = useState(false);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>;
|
||||
```
|
||||
|
||||
**Accessibility:**
|
||||
|
||||
- Escape key closes dialog
|
||||
- Focus trapped inside dialog
|
||||
- Returns focus to trigger on close
|
||||
@@ -916,7 +920,7 @@ import {
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption
|
||||
TableCaption,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
<Table>
|
||||
@@ -945,7 +949,7 @@ import {
|
||||
<TableCell className="text-right">$2,500.00</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</Table>;
|
||||
```
|
||||
|
||||
**For advanced tables** (sorting, filtering, pagination), use **TanStack Table** with react-hook-form.
|
||||
@@ -1014,12 +1018,14 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map(user => (
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.name}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm">Edit</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
Edit
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -1041,9 +1047,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New User</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new user to the system
|
||||
</DialogDescription>
|
||||
<DialogDescription>Add a new user to the system</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -1113,7 +1117,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
```tsx
|
||||
<Table>
|
||||
<TableBody>
|
||||
{users.map(user => (
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.name}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
@@ -1134,10 +1138,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(user)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<DropdownMenuItem onClick={() => handleDelete(user)} className="text-destructive">
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -1187,6 +1188,7 @@ Need switchable panels? → Tabs
|
||||
### Component Variants Quick Reference
|
||||
|
||||
**Button**:
|
||||
|
||||
- `default` - Primary action
|
||||
- `secondary` - Secondary action
|
||||
- `outline` - Alternative action
|
||||
@@ -1195,12 +1197,14 @@ Need switchable panels? → Tabs
|
||||
- `destructive` - Delete/remove
|
||||
|
||||
**Badge**:
|
||||
|
||||
- `default` - Blue (new, active)
|
||||
- `secondary` - Gray (draft, inactive)
|
||||
- `outline` - Bordered (pending)
|
||||
- `destructive` - Red (critical, error)
|
||||
|
||||
**Alert**:
|
||||
|
||||
- `default` - Info
|
||||
- `destructive` - Error
|
||||
|
||||
@@ -1216,12 +1220,14 @@ Need switchable panels? → Tabs
|
||||
---
|
||||
|
||||
**Related Documentation:**
|
||||
|
||||
- [Quick Start](./00-quick-start.md) - Essential patterns
|
||||
- [Foundations](./01-foundations.md) - Colors, typography, spacing
|
||||
- [Layouts](./03-layouts.md) - Layout patterns
|
||||
- [Forms](./06-forms.md) - Form validation and patterns
|
||||
|
||||
**External Resources:**
|
||||
|
||||
- [shadcn/ui Documentation](https://ui.shadcn.com)
|
||||
- [Radix UI Primitives](https://www.radix-ui.com)
|
||||
|
||||
|
||||
@@ -36,16 +36,16 @@ Use this flowchart to choose between Grid and Flex:
|
||||
|
||||
### Quick Rules
|
||||
|
||||
| Scenario | Solution |
|
||||
|----------|----------|
|
||||
| **Equal-width columns** | Grid (`grid grid-cols-3`) |
|
||||
| **Flexible item sizes** | Flex (`flex gap-4`) |
|
||||
| **2D layout (rows + cols)** | Grid (`grid grid-cols-2 grid-rows-3`) |
|
||||
| **1D layout (row OR col)** | Flex (`flex` or `flex flex-col`) |
|
||||
| **Card grid** | Grid (`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3`) |
|
||||
| **Navbar items** | Flex (`flex items-center gap-4`) |
|
||||
| **Sidebar + Content** | Flex (`flex gap-6`) |
|
||||
| **Form fields** | Flex column (`flex flex-col gap-4` or `space-y-4`) |
|
||||
| Scenario | Solution |
|
||||
| --------------------------- | ------------------------------------------------------- |
|
||||
| **Equal-width columns** | Grid (`grid grid-cols-3`) |
|
||||
| **Flexible item sizes** | Flex (`flex gap-4`) |
|
||||
| **2D layout (rows + cols)** | Grid (`grid grid-cols-2 grid-rows-3`) |
|
||||
| **1D layout (row OR col)** | Flex (`flex` or `flex flex-col`) |
|
||||
| **Card grid** | Grid (`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3`) |
|
||||
| **Navbar items** | Flex (`flex items-center gap-4`) |
|
||||
| **Sidebar + Content** | Flex (`flex gap-6`) |
|
||||
| **Form fields** | Flex column (`flex flex-col gap-4` or `space-y-4`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -68,15 +68,14 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
<CardHeader>
|
||||
<CardTitle>Section Title</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
Page content goes here
|
||||
</CardContent>
|
||||
<CardContent>Page content goes here</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- `container` - Responsive container with max-width
|
||||
- `mx-auto` - Center horizontally
|
||||
- `px-4` - Horizontal padding (mobile-friendly)
|
||||
@@ -85,6 +84,7 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
- `space-y-6` - Vertical spacing between children
|
||||
|
||||
**When to use:**
|
||||
|
||||
- Blog posts
|
||||
- Documentation pages
|
||||
- Settings pages
|
||||
@@ -103,7 +103,7 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{items.map(item => (
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{item.title}</CardTitle>
|
||||
@@ -119,11 +119,13 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
```
|
||||
|
||||
**Responsive behavior:**
|
||||
|
||||
- **Mobile** (`< 768px`): 1 column
|
||||
- **Tablet** (`≥ 768px`): 2 columns
|
||||
- **Desktop** (`≥ 1024px`): 3 columns
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- `grid` - Use CSS Grid
|
||||
- `grid-cols-1` - Default: 1 column (mobile-first)
|
||||
- `md:grid-cols-2` - 2 columns on tablet
|
||||
@@ -131,6 +133,7 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
- `gap-6` - Consistent spacing between items
|
||||
|
||||
**When to use:**
|
||||
|
||||
- Dashboards
|
||||
- Product grids
|
||||
- Image galleries
|
||||
@@ -171,17 +174,20 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- `max-w-md` - Constrain form width (448px max)
|
||||
- `mx-auto` - Center the form
|
||||
- `space-y-4` - Vertical spacing between fields
|
||||
- `w-full` - Full-width button
|
||||
|
||||
**Form width guidelines:**
|
||||
|
||||
- **Short forms** (login, signup): `max-w-md` (448px)
|
||||
- **Medium forms** (profile, settings): `max-w-lg` (512px)
|
||||
- **Long forms** (checkout): `max-w-2xl` (672px)
|
||||
|
||||
**When to use:**
|
||||
|
||||
- Login/signup forms
|
||||
- Contact forms
|
||||
- Settings forms
|
||||
@@ -220,6 +226,7 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- `flex` - Horizontal layout
|
||||
- `w-64` - Fixed sidebar width (256px)
|
||||
- `flex-1` - Main content takes remaining space
|
||||
@@ -249,6 +256,7 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
|
||||
- Admin dashboards
|
||||
- Settings pages
|
||||
- Documentation sites
|
||||
@@ -277,17 +285,20 @@ These 5 patterns cover 80% of all layout needs. Master these first.
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- `max-w-2xl` - Optimal reading width (672px)
|
||||
- `mx-auto` - Center content
|
||||
- `prose` - Typography styles (if using @tailwindcss/typography)
|
||||
|
||||
**Width recommendations:**
|
||||
|
||||
- **Articles/Blogs**: `max-w-2xl` (672px)
|
||||
- **Documentation**: `max-w-3xl` (768px)
|
||||
- **Landing pages**: `max-w-4xl` (896px) or wider
|
||||
- **Forms**: `max-w-md` (448px)
|
||||
|
||||
**When to use:**
|
||||
|
||||
- Blog posts
|
||||
- Articles
|
||||
- Documentation
|
||||
@@ -327,13 +338,13 @@ Always start with mobile layout, then enhance for larger screens:
|
||||
|
||||
### Breakpoints
|
||||
|
||||
| Breakpoint | Min Width | Typical Use |
|
||||
|------------|-----------|-------------|
|
||||
| `sm:` | 640px | Large phones, small tablets |
|
||||
| `md:` | 768px | Tablets |
|
||||
| `lg:` | 1024px | Laptops, desktops |
|
||||
| `xl:` | 1280px | Large desktops |
|
||||
| `2xl:` | 1536px | Extra large screens |
|
||||
| Breakpoint | Min Width | Typical Use |
|
||||
| ---------- | --------- | --------------------------- |
|
||||
| `sm:` | 640px | Large phones, small tablets |
|
||||
| `md:` | 768px | Tablets |
|
||||
| `lg:` | 1024px | Laptops, desktops |
|
||||
| `xl:` | 1280px | Large desktops |
|
||||
| `2xl:` | 1536px | Extra large screens |
|
||||
|
||||
### Responsive Grid Columns
|
||||
|
||||
@@ -457,12 +468,8 @@ grid-cols-1 lg:grid-cols-3
|
||||
```tsx
|
||||
// 2/3 - 1/3 split
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div className="col-span-2">
|
||||
Main content (2/3 width)
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
Sidebar (1/3 width)
|
||||
</div>
|
||||
<div className="col-span-2">Main content (2/3 width)</div>
|
||||
<div className="col-span-1">Sidebar (1/3 width)</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
@@ -482,12 +489,8 @@ grid-cols-1 lg:grid-cols-3
|
||||
|
||||
```tsx
|
||||
<div className="flex gap-6">
|
||||
<aside className="sticky top-6 h-fit w-64">
|
||||
{/* Stays in view while scrolling */}
|
||||
</aside>
|
||||
<main className="flex-1">
|
||||
{/* Scrollable content */}
|
||||
</main>
|
||||
<aside className="sticky top-6 h-fit w-64">{/* Stays in view while scrolling */}</aside>
|
||||
<main className="flex-1">{/* Scrollable content */}</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
@@ -579,6 +582,7 @@ w-full px-4
|
||||
---
|
||||
|
||||
**Related Documentation:**
|
||||
|
||||
- [Spacing Philosophy](./04-spacing-philosophy.md) - When to use margin vs padding vs gap
|
||||
- [Foundations](./01-foundations.md) - Spacing tokens and scale
|
||||
- [Quick Start](./00-quick-start.md) - Essential patterns
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
These 5 rules eliminate 90% of spacing inconsistencies:
|
||||
|
||||
### Rule 1: Parent Controls Children
|
||||
|
||||
**Children don't add their own margins. The parent controls spacing between siblings.**
|
||||
|
||||
```tsx
|
||||
@@ -40,6 +41,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
|
||||
- Eliminates "last child" edge cases
|
||||
- Makes components reusable (they work in any context)
|
||||
- Changes propagate from one place (parent)
|
||||
@@ -48,6 +50,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
|
||||
---
|
||||
|
||||
### Rule 2: Use Gap for Siblings
|
||||
|
||||
**For flex and grid layouts, use `gap-*` to space siblings.**
|
||||
|
||||
```tsx
|
||||
@@ -73,6 +76,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
|
||||
---
|
||||
|
||||
### Rule 3: Use Padding for Internal Spacing
|
||||
|
||||
**Padding is for spacing _inside_ a component, between the border and content.**
|
||||
|
||||
```tsx
|
||||
@@ -91,6 +95,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
|
||||
---
|
||||
|
||||
### Rule 4: Use space-y for Vertical Stacks
|
||||
|
||||
**For vertical stacks (not flex/grid), use `space-y-*` utility.**
|
||||
|
||||
```tsx
|
||||
@@ -110,6 +115,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
|
||||
```
|
||||
|
||||
**How space-y works:**
|
||||
|
||||
```css
|
||||
/* space-y-4 applies margin-top to all children except first */
|
||||
.space-y-4 > * + * {
|
||||
@@ -120,6 +126,7 @@ These 5 rules eliminate 90% of spacing inconsistencies:
|
||||
---
|
||||
|
||||
### Rule 5: Margins Only for Exceptions
|
||||
|
||||
**Use margin only when a specific child needs different spacing from its siblings.**
|
||||
|
||||
```tsx
|
||||
@@ -151,18 +158,19 @@ When children control their own margins:
|
||||
```tsx
|
||||
// ❌ ANTI-PATTERN
|
||||
function TodoItem({ className }: { className?: string }) {
|
||||
return <div className={cn("mb-4", className)}>Todo</div>;
|
||||
return <div className={cn('mb-4', className)}>Todo</div>;
|
||||
}
|
||||
|
||||
// Usage
|
||||
<div>
|
||||
<TodoItem /> {/* Has mb-4 */}
|
||||
<TodoItem /> {/* Has mb-4 */}
|
||||
<TodoItem /> {/* Has mb-4 - unwanted margin at bottom! */}
|
||||
</div>
|
||||
<TodoItem /> {/* Has mb-4 */}
|
||||
<TodoItem /> {/* Has mb-4 */}
|
||||
<TodoItem /> {/* Has mb-4 - unwanted margin at bottom! */}
|
||||
</div>;
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
|
||||
1. ❌ Last item has unwanted margin
|
||||
2. ❌ Can't change spacing without modifying component
|
||||
3. ❌ Margin collapsing creates unpredictable spacing
|
||||
@@ -199,6 +207,7 @@ function TodoItem({ className }: { className?: string }) {
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
1. ✅ No edge cases (last child, first child, only child)
|
||||
2. ✅ Spacing controlled in one place
|
||||
3. ✅ Component works in any layout context
|
||||
@@ -265,6 +274,7 @@ Use this flowchart to choose the right spacing method:
|
||||
```
|
||||
|
||||
**Spacing breakdown:**
|
||||
|
||||
- `space-y-4` on form: 16px between field groups
|
||||
- `space-y-2` on field group: 8px between label and input
|
||||
- No margins on children
|
||||
@@ -288,6 +298,7 @@ Use this flowchart to choose the right spacing method:
|
||||
```
|
||||
|
||||
**Why gap over space-x:**
|
||||
|
||||
- Works with `flex-wrap`
|
||||
- Works with `flex-col` (changes direction)
|
||||
- Consistent spacing in all directions
|
||||
@@ -306,6 +317,7 @@ Use this flowchart to choose the right spacing method:
|
||||
```
|
||||
|
||||
**Why gap:**
|
||||
|
||||
- Consistent spacing between rows and columns
|
||||
- Works with responsive grid changes
|
||||
- No edge cases (first row, last column, etc.)
|
||||
@@ -332,6 +344,7 @@ Use this flowchart to choose the right spacing method:
|
||||
```
|
||||
|
||||
**Spacing breakdown:**
|
||||
|
||||
- `p-6` on Card: 24px internal padding
|
||||
- `space-y-4` on CardContent: 16px between paragraphs
|
||||
- `pt-4` on CardFooter: Additional top padding for visual separation
|
||||
@@ -364,6 +377,7 @@ Use this flowchart to choose the right spacing method:
|
||||
```
|
||||
|
||||
**Spacing breakdown:**
|
||||
|
||||
- `px-4`: Horizontal padding (prevents edge touching)
|
||||
- `py-8`: Vertical padding (top and bottom spacing)
|
||||
- `space-y-6`: 24px between sections
|
||||
@@ -376,25 +390,28 @@ Use this flowchart to choose the right spacing method:
|
||||
### Example 1: Button Group
|
||||
|
||||
#### ❌ Before (Child-Controlled)
|
||||
|
||||
```tsx
|
||||
function ActionButton({ children, className }: Props) {
|
||||
return <Button className={cn("mr-4", className)}>{children}</Button>;
|
||||
return <Button className={cn('mr-4', className)}>{children}</Button>;
|
||||
}
|
||||
|
||||
// Usage
|
||||
<div className="flex">
|
||||
<ActionButton>Cancel</ActionButton>
|
||||
<ActionButton>Save</ActionButton>
|
||||
<ActionButton>Delete</ActionButton> {/* Unwanted mr-4 */}
|
||||
</div>
|
||||
<ActionButton>Delete</ActionButton> {/* Unwanted mr-4 */}
|
||||
</div>;
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
|
||||
- Last button has unwanted margin
|
||||
- Can't change spacing without modifying component
|
||||
- Hard to use in vertical layout
|
||||
|
||||
#### ✅ After (Parent-Controlled)
|
||||
|
||||
```tsx
|
||||
function ActionButton({ children, className }: Props) {
|
||||
return <Button className={className}>{children}</Button>;
|
||||
@@ -415,6 +432,7 @@ function ActionButton({ children, className }: Props) {
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- No edge cases
|
||||
- Reusable in any layout
|
||||
- Easy to change spacing
|
||||
@@ -424,6 +442,7 @@ function ActionButton({ children, className }: Props) {
|
||||
### Example 2: List Items
|
||||
|
||||
#### ❌ Before (Child-Controlled)
|
||||
|
||||
```tsx
|
||||
function ListItem({ title, description }: Props) {
|
||||
return (
|
||||
@@ -437,16 +456,18 @@ function ListItem({ title, description }: Props) {
|
||||
<div>
|
||||
<ListItem title="Item 1" description="..." />
|
||||
<ListItem title="Item 2" description="..." />
|
||||
<ListItem title="Item 3" description="..." /> {/* Unwanted mb-6 */}
|
||||
</div>
|
||||
<ListItem title="Item 3" description="..." /> {/* Unwanted mb-6 */}
|
||||
</div>;
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
|
||||
- Last item has unwanted bottom margin
|
||||
- Can't change list spacing without modifying component
|
||||
- Internal `mb-2` hard to override
|
||||
|
||||
#### ✅ After (Parent-Controlled)
|
||||
|
||||
```tsx
|
||||
function ListItem({ title, description }: Props) {
|
||||
return (
|
||||
@@ -473,6 +494,7 @@ function ListItem({ title, description }: Props) {
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- No unwanted margins
|
||||
- Internal spacing controlled by `space-y-2`
|
||||
- Reusable with different spacings
|
||||
@@ -482,6 +504,7 @@ function ListItem({ title, description }: Props) {
|
||||
### Example 3: Form Fields
|
||||
|
||||
#### ❌ Before (Mixed Strategy)
|
||||
|
||||
```tsx
|
||||
<form>
|
||||
<div className="mb-4">
|
||||
@@ -494,16 +517,20 @@ function ListItem({ title, description }: Props) {
|
||||
<Input id="email" className="mt-2" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="mt-6">Submit</Button>
|
||||
<Button type="submit" className="mt-6">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
|
||||
- Spacing scattered across children
|
||||
- Hard to change consistently
|
||||
- Have to remember `mt-6` for button
|
||||
|
||||
#### ✅ After (Parent-Controlled)
|
||||
|
||||
```tsx
|
||||
<form className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -516,11 +543,14 @@ function ListItem({ title, description }: Props) {
|
||||
<Input id="email" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="mt-2">Submit</Button>
|
||||
<Button type="submit" className="mt-2">
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
|
||||
- Spacing controlled in 2 places: form (`space-y-4`) and field groups (`space-y-2`)
|
||||
- Easy to change all field spacing at once
|
||||
- Consistent and predictable
|
||||
@@ -533,18 +563,20 @@ function ListItem({ title, description }: Props) {
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG
|
||||
{items.map((item, index) => (
|
||||
<Card key={item.id} className={index < items.length - 1 ? "mb-4" : ""}>
|
||||
{item.name}
|
||||
</Card>
|
||||
))}
|
||||
{
|
||||
items.map((item, index) => (
|
||||
<Card key={item.id} className={index < items.length - 1 ? 'mb-4' : ''}>
|
||||
{item.name}
|
||||
</Card>
|
||||
));
|
||||
}
|
||||
|
||||
// ✅ CORRECT
|
||||
<div className="space-y-4">
|
||||
{items.map(item => (
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>{item.name}</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -564,6 +596,7 @@ function ListItem({ title, description }: Props) {
|
||||
```
|
||||
|
||||
**Why negative margins are bad:**
|
||||
|
||||
- Indicates broken spacing strategy
|
||||
- Hard to maintain
|
||||
- Creates coupling between components
|
||||
@@ -618,26 +651,26 @@ function ListItem({ title, description }: Props) {
|
||||
|
||||
### Spacing Method Cheat Sheet
|
||||
|
||||
| Use Case | Method | Example |
|
||||
|----------|--------|---------|
|
||||
| **Flex siblings** | `gap-*` | `flex gap-4` |
|
||||
| **Grid siblings** | `gap-*` | `grid gap-6` |
|
||||
| **Vertical stack** | `space-y-*` | `space-y-4` |
|
||||
| **Horizontal stack** | `space-x-*` | `space-x-2` |
|
||||
| **Inside component** | `p-*` | `p-6` |
|
||||
| **One child exception** | `m-*` | `mt-8` |
|
||||
| Use Case | Method | Example |
|
||||
| ----------------------- | ----------- | ------------ |
|
||||
| **Flex siblings** | `gap-*` | `flex gap-4` |
|
||||
| **Grid siblings** | `gap-*` | `grid gap-6` |
|
||||
| **Vertical stack** | `space-y-*` | `space-y-4` |
|
||||
| **Horizontal stack** | `space-x-*` | `space-x-2` |
|
||||
| **Inside component** | `p-*` | `p-6` |
|
||||
| **One child exception** | `m-*` | `mt-8` |
|
||||
|
||||
### Common Spacing Values
|
||||
|
||||
| Class | Pixels | Usage |
|
||||
|-------|--------|-------|
|
||||
| `gap-2` or `space-y-2` | 8px | Tight (label + input) |
|
||||
| `gap-4` or `space-y-4` | 16px | Standard (form fields) |
|
||||
| `gap-6` or `space-y-6` | 24px | Sections (cards) |
|
||||
| `gap-8` or `space-y-8` | 32px | Large gaps |
|
||||
| `p-4` | 16px | Standard padding |
|
||||
| `p-6` | 24px | Card padding |
|
||||
| `px-4 py-8` | 16px / 32px | Page padding |
|
||||
| Class | Pixels | Usage |
|
||||
| ---------------------- | ----------- | ---------------------- |
|
||||
| `gap-2` or `space-y-2` | 8px | Tight (label + input) |
|
||||
| `gap-4` or `space-y-4` | 16px | Standard (form fields) |
|
||||
| `gap-6` or `space-y-6` | 24px | Sections (cards) |
|
||||
| `gap-8` or `space-y-8` | 32px | Large gaps |
|
||||
| `p-4` | 16px | Standard padding |
|
||||
| `p-6` | 24px | Card padding |
|
||||
| `px-4 py-8` | 16px / 32px | Page padding |
|
||||
|
||||
### Decision Flowchart (Simplified)
|
||||
|
||||
@@ -682,7 +715,7 @@ Need spacing?
|
||||
Before implementing spacing, verify:
|
||||
|
||||
- [ ] **Parent controls children?** Using gap or space-y/x?
|
||||
- [ ] **No child margins?** Components don't have mb-* or mr-*?
|
||||
- [ ] **No child margins?** Components don't have mb-_ or mr-_?
|
||||
- [ ] **Consistent method?** Not mixing gap + child margins?
|
||||
- [ ] **Reusable components?** Work in different contexts?
|
||||
- [ ] **No edge cases?** No last-child or first-child special handling?
|
||||
@@ -700,6 +733,7 @@ Before implementing spacing, verify:
|
||||
---
|
||||
|
||||
**Related Documentation:**
|
||||
|
||||
- [Layouts](./03-layouts.md) - When to use Grid vs Flex
|
||||
- [Foundations](./01-foundations.md) - Spacing scale tokens
|
||||
- [Component Creation](./05-component-creation.md) - Building reusable components
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
**80% of the time, you should COMPOSE existing shadcn/ui components.**
|
||||
|
||||
Only create custom components when:
|
||||
|
||||
1. ✅ You're reusing the same composition 3+ times
|
||||
2. ✅ The pattern has complex business logic
|
||||
3. ✅ You need variants beyond what shadcn/ui provides
|
||||
@@ -74,6 +75,7 @@ Do you need a UI element?
|
||||
```
|
||||
|
||||
**Why this is good:**
|
||||
|
||||
- Simple and direct
|
||||
- Easy to customize per use case
|
||||
- No abstraction overhead
|
||||
@@ -103,10 +105,11 @@ function ContentCard({ title, description, content, actionLabel, onAction }: Pro
|
||||
}
|
||||
|
||||
// Used once... why did we create this?
|
||||
<ContentCard title="..." description="..." content="..." />
|
||||
<ContentCard title="..." description="..." content="..." />;
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
|
||||
- ❌ Created before knowing if pattern is reused
|
||||
- ❌ Inflexible (what if we need 2 buttons?)
|
||||
- ❌ Unclear what it renders (abstraction hides structure)
|
||||
@@ -148,6 +151,7 @@ function DashboardMetricCard({
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
|
||||
- ✅ Pattern validated (used 3+ times)
|
||||
- ✅ Specific purpose (dashboard metrics)
|
||||
- ✅ Consistent structure across uses
|
||||
@@ -171,22 +175,23 @@ interface MyComponentProps {
|
||||
|
||||
export function MyComponent({ className, children }: MyComponentProps) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"base-classes-here", // Base styles
|
||||
className // Allow overrides
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'base-classes-here', // Base styles
|
||||
className // Allow overrides
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Usage
|
||||
<MyComponent className="custom-overrides">
|
||||
Content
|
||||
</MyComponent>
|
||||
<MyComponent className="custom-overrides">Content</MyComponent>;
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Always accept `className` prop
|
||||
- Use `cn()` utility for merging
|
||||
- Base classes first, overrides last
|
||||
@@ -203,24 +208,24 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
const componentVariants = cva(
|
||||
// Base classes (always applied)
|
||||
"inline-flex items-center justify-center rounded-lg font-medium transition-colors",
|
||||
'inline-flex items-center justify-center rounded-lg font-medium transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
sm: "h-8 px-3 text-xs",
|
||||
default: "h-10 px-4 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
default: 'h-10 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -231,25 +236,18 @@ interface MyComponentProps
|
||||
// Additional props here
|
||||
}
|
||||
|
||||
export function MyComponent({
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
...props
|
||||
}: MyComponentProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(componentVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
export function MyComponent({ variant, size, className, ...props }: MyComponentProps) {
|
||||
return <div className={cn(componentVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
// Usage
|
||||
<MyComponent variant="outline" size="lg">Content</MyComponent>
|
||||
<MyComponent variant="outline" size="lg">
|
||||
Content
|
||||
</MyComponent>;
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Use CVA for complex variant logic
|
||||
- Always provide `defaultVariants`
|
||||
- Extend `React.HTMLAttributes` for standard HTML props
|
||||
@@ -273,13 +271,7 @@ interface StatCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
description,
|
||||
icon,
|
||||
className,
|
||||
}: StatCardProps) {
|
||||
export function StatCard({ title, value, description, icon, className }: StatCardProps) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
@@ -288,9 +280,7 @@ export function StatCard({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -302,10 +292,11 @@ export function StatCard({
|
||||
value="1,234"
|
||||
description="+12% from last month"
|
||||
icon={<Users className="h-4 w-4 text-muted-foreground" />}
|
||||
/>
|
||||
/>;
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Compose from shadcn/ui primitives
|
||||
- Keep structure consistent
|
||||
- Optional props with `?`
|
||||
@@ -354,14 +345,17 @@ export function Toggle({
|
||||
}
|
||||
|
||||
// Uncontrolled usage
|
||||
<Toggle defaultValue={false}>Auto-save</Toggle>
|
||||
<Toggle defaultValue={false}>Auto-save</Toggle>;
|
||||
|
||||
// Controlled usage
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
<Toggle value={enabled} onChange={setEnabled}>Auto-save</Toggle>
|
||||
<Toggle value={enabled} onChange={setEnabled}>
|
||||
Auto-save
|
||||
</Toggle>;
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- Support both controlled and uncontrolled modes
|
||||
- Use `defaultValue` for initial uncontrolled value
|
||||
- Use `value` + `onChange` for controlled mode
|
||||
@@ -376,6 +370,7 @@ const [enabled, setEnabled] = useState(false);
|
||||
**class-variance-authority** (CVA) is a utility for creating component variants with Tailwind CSS.
|
||||
|
||||
**Why use CVA?**
|
||||
|
||||
- ✅ Type-safe variant props
|
||||
- ✅ Compound variants (combinations)
|
||||
- ✅ Default variants
|
||||
@@ -390,24 +385,23 @@ import { cva } from 'class-variance-authority';
|
||||
|
||||
const alertVariants = cva(
|
||||
// Base classes (always applied)
|
||||
"relative w-full rounded-lg border p-4",
|
||||
'relative w-full rounded-lg border p-4',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
default: 'bg-background text-foreground',
|
||||
destructive:
|
||||
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Usage
|
||||
<div className={alertVariants({ variant: "destructive" })}>
|
||||
Alert content
|
||||
</div>
|
||||
<div className={alertVariants({ variant: 'destructive' })}>Alert content</div>;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -416,32 +410,32 @@ const alertVariants = cva(
|
||||
|
||||
```tsx
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-colors",
|
||||
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
sm: "h-8 px-3 text-xs",
|
||||
default: "h-10 px-4 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
default: 'h-10 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Usage
|
||||
<button className={buttonVariants({ variant: "outline", size: "lg" })}>
|
||||
<button className={buttonVariants({ variant: 'outline', size: 'lg' })}>
|
||||
Large Outline Button
|
||||
</button>
|
||||
</button>;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -451,28 +445,28 @@ const buttonVariants = cva(
|
||||
**Use case**: Different classes when specific variant combinations are used
|
||||
|
||||
```tsx
|
||||
const buttonVariants = cva("base-classes", {
|
||||
const buttonVariants = cva('base-classes', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary",
|
||||
destructive: "bg-destructive",
|
||||
default: 'bg-primary',
|
||||
destructive: 'bg-destructive',
|
||||
},
|
||||
size: {
|
||||
sm: "h-8",
|
||||
lg: "h-12",
|
||||
sm: 'h-8',
|
||||
lg: 'h-12',
|
||||
},
|
||||
},
|
||||
// Compound variants: specific combinations
|
||||
compoundVariants: [
|
||||
{
|
||||
variant: "destructive",
|
||||
size: "lg",
|
||||
class: "text-lg font-bold", // Applied when BOTH are true
|
||||
variant: 'destructive',
|
||||
size: 'lg',
|
||||
class: 'text-lg font-bold', // Applied when BOTH are true
|
||||
},
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "sm",
|
||||
variant: 'default',
|
||||
size: 'sm',
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -484,6 +478,7 @@ const buttonVariants = cva("base-classes", {
|
||||
### Prop Naming Conventions
|
||||
|
||||
**DO**:
|
||||
|
||||
```tsx
|
||||
// ✅ Descriptive, semantic names
|
||||
interface UserCardProps {
|
||||
@@ -495,6 +490,7 @@ interface UserCardProps {
|
||||
```
|
||||
|
||||
**DON'T**:
|
||||
|
||||
```tsx
|
||||
// ❌ Generic, unclear names
|
||||
interface CardProps {
|
||||
@@ -510,6 +506,7 @@ interface CardProps {
|
||||
### Required vs Optional Props
|
||||
|
||||
**Guidelines:**
|
||||
|
||||
- Required: Core functionality depends on it
|
||||
- Optional: Nice-to-have, has sensible default
|
||||
|
||||
@@ -531,7 +528,7 @@ interface AlertProps {
|
||||
|
||||
export function Alert({
|
||||
children,
|
||||
variant = 'default', // Default for optional prop
|
||||
variant = 'default', // Default for optional prop
|
||||
onClose,
|
||||
icon,
|
||||
className,
|
||||
@@ -545,6 +542,7 @@ export function Alert({
|
||||
### Prop Type Patterns
|
||||
|
||||
**Enum props** (limited options):
|
||||
|
||||
```tsx
|
||||
interface ButtonProps {
|
||||
variant: 'default' | 'destructive' | 'outline';
|
||||
@@ -553,6 +551,7 @@ interface ButtonProps {
|
||||
```
|
||||
|
||||
**Boolean flags**:
|
||||
|
||||
```tsx
|
||||
interface CardProps {
|
||||
isLoading?: boolean;
|
||||
@@ -562,6 +561,7 @@ interface CardProps {
|
||||
```
|
||||
|
||||
**Callback props**:
|
||||
|
||||
```tsx
|
||||
interface FormProps {
|
||||
onSubmit: (data: FormData) => void;
|
||||
@@ -571,6 +571,7 @@ interface FormProps {
|
||||
```
|
||||
|
||||
**Render props** (advanced customization):
|
||||
|
||||
```tsx
|
||||
interface ListProps<T> {
|
||||
items: T[];
|
||||
@@ -583,7 +584,7 @@ interface ListProps<T> {
|
||||
items={users}
|
||||
renderItem={(user, i) => <UserCard key={i} user={user} />}
|
||||
renderEmpty={() => <EmptyState />}
|
||||
/>
|
||||
/>;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -593,6 +594,7 @@ interface ListProps<T> {
|
||||
Before shipping a custom component, verify:
|
||||
|
||||
### Visual Testing
|
||||
|
||||
- [ ] **Light mode** - Component looks correct
|
||||
- [ ] **Dark mode** - Component looks correct (toggle theme)
|
||||
- [ ] **All variants** - Test each variant works
|
||||
@@ -602,6 +604,7 @@ Before shipping a custom component, verify:
|
||||
- [ ] **Empty state** - Handles no data gracefully
|
||||
|
||||
### Accessibility Testing
|
||||
|
||||
- [ ] **Keyboard navigation** - Can be focused and activated with Tab/Enter
|
||||
- [ ] **Focus indicators** - Visible focus ring (`:focus-visible`)
|
||||
- [ ] **Screen reader** - ARIA labels and roles present
|
||||
@@ -609,6 +612,7 @@ Before shipping a custom component, verify:
|
||||
- [ ] **Semantic HTML** - Using correct HTML elements (button, nav, etc.)
|
||||
|
||||
### Functional Testing
|
||||
|
||||
- [ ] **Props work** - All props apply correctly
|
||||
- [ ] **className override** - Can override styles with className prop
|
||||
- [ ] **Controlled/uncontrolled** - Both modes work (if applicable)
|
||||
@@ -616,6 +620,7 @@ Before shipping a custom component, verify:
|
||||
- [ ] **TypeScript** - No type errors, props autocomplete
|
||||
|
||||
### Code Quality
|
||||
|
||||
- [ ] **No console errors** - Check browser console
|
||||
- [ ] **No warnings** - React warnings, a11y warnings
|
||||
- [ ] **Performance** - No unnecessary re-renders
|
||||
@@ -644,13 +649,7 @@ interface StatCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
icon: Icon,
|
||||
className,
|
||||
}: StatCardProps) {
|
||||
export function StatCard({ title, value, change, icon: Icon, className }: StatCardProps) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
@@ -660,11 +659,9 @@ export function StatCard({
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{change !== undefined && (
|
||||
<p className={cn(
|
||||
"text-xs",
|
||||
change >= 0 ? "text-green-600" : "text-destructive"
|
||||
)}>
|
||||
{change >= 0 ? '+' : ''}{change}% from last month
|
||||
<p className={cn('text-xs', change >= 0 ? 'text-green-600' : 'text-destructive')}>
|
||||
{change >= 0 ? '+' : ''}
|
||||
{change}% from last month
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -678,10 +675,11 @@ export function StatCard({
|
||||
<StatCard title="Subscriptions" value="+2350" change={12.5} icon={Users} />
|
||||
<StatCard title="Sales" value="+12,234" change={19} icon={CreditCard} />
|
||||
<StatCard title="Active Now" value="+573" change={-2.1} icon={Activity} />
|
||||
</div>
|
||||
</div>;
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
|
||||
- Specific purpose (dashboard metrics)
|
||||
- Reused 8+ times
|
||||
- Consistent structure
|
||||
@@ -747,18 +745,10 @@ export function ConfirmDialog({
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Button variant={variant} onClick={handleConfirm} disabled={isLoading}>
|
||||
{isLoading ? 'Processing...' : confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -781,10 +771,11 @@ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
await deleteUser(user.id);
|
||||
toast.success('User deleted');
|
||||
}}
|
||||
/>
|
||||
/>;
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
|
||||
- Common pattern (confirmations)
|
||||
- Handles loading states automatically
|
||||
- Consistent UX across app
|
||||
@@ -808,19 +799,12 @@ interface PageHeaderProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: PageHeaderProps) {
|
||||
export function PageHeader({ title, description, action, className }: PageHeaderProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between", className)}>
|
||||
<div className={cn('flex items-center justify-between', className)}>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
|
||||
{description && (
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{description && <p className="text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
@@ -837,7 +821,7 @@ export function PageHeader({
|
||||
Create User
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
/>;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -866,6 +850,7 @@ Before creating a custom component, ask:
|
||||
---
|
||||
|
||||
**Related Documentation:**
|
||||
|
||||
- [Components](./02-components.md) - shadcn/ui component library
|
||||
- [AI Guidelines](./08-ai-guidelines.md) - Component templates for AI
|
||||
- [Forms](./06-forms.md) - Form component patterns
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
- **shadcn/ui components** - Input, Label, Button, etc.
|
||||
|
||||
**Why this stack?**
|
||||
|
||||
- ✅ Type-safe validation (TypeScript + Zod)
|
||||
- ✅ Minimal re-renders (react-hook-form)
|
||||
- ✅ Accessible by default (shadcn/ui)
|
||||
@@ -80,11 +81,7 @@ export function SimpleForm() {
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
{...form.register('email')}
|
||||
/>
|
||||
<Input id="email" type="email" {...form.register('email')} />
|
||||
</div>
|
||||
|
||||
<Button type="submit">Submit</Button>
|
||||
@@ -180,6 +177,7 @@ export function LoginForm() {
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
1. Define Zod schema first
|
||||
2. Infer TypeScript type with `z.infer`
|
||||
3. Use `zodResolver` in `useForm`
|
||||
@@ -217,15 +215,9 @@ export function LoginForm() {
|
||||
```tsx
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
rows={4}
|
||||
{...form.register('description')}
|
||||
/>
|
||||
<Textarea id="description" rows={4} {...form.register('description')} />
|
||||
{form.formState.errors.description && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.description.message}
|
||||
</p>
|
||||
<p className="text-sm text-destructive">{form.formState.errors.description.message}</p>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
@@ -237,10 +229,7 @@ export function LoginForm() {
|
||||
```tsx
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Select
|
||||
value={form.watch('role')}
|
||||
onValueChange={(value) => form.setValue('role', value)}
|
||||
>
|
||||
<Select value={form.watch('role')} onValueChange={(value) => form.setValue('role', value)}>
|
||||
<SelectTrigger id="role">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
@@ -251,9 +240,7 @@ export function LoginForm() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.role && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.role.message}
|
||||
</p>
|
||||
<p className="text-sm text-destructive">{form.formState.errors.role.message}</p>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
@@ -272,12 +259,12 @@ export function LoginForm() {
|
||||
<Label htmlFor="terms" className="text-sm font-normal">
|
||||
I accept the terms and conditions
|
||||
</Label>
|
||||
</div>
|
||||
{form.formState.errors.acceptTerms && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.acceptTerms.message}
|
||||
</p>
|
||||
)}
|
||||
</div>;
|
||||
{
|
||||
form.formState.errors.acceptTerms && (
|
||||
<p className="text-sm text-destructive">{form.formState.errors.acceptTerms.message}</p>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -289,22 +276,16 @@ export function LoginForm() {
|
||||
<Label>Notification Method</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="email"
|
||||
value="email"
|
||||
{...form.register('notificationMethod')}
|
||||
/>
|
||||
<Label htmlFor="email" className="font-normal">Email</Label>
|
||||
<input type="radio" id="email" value="email" {...form.register('notificationMethod')} />
|
||||
<Label htmlFor="email" className="font-normal">
|
||||
Email
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
id="sms"
|
||||
value="sms"
|
||||
{...form.register('notificationMethod')}
|
||||
/>
|
||||
<Label htmlFor="sms" className="font-normal">SMS</Label>
|
||||
<input type="radio" id="sms" value="sms" {...form.register('notificationMethod')} />
|
||||
<Label htmlFor="sms" className="font-normal">
|
||||
SMS
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -320,65 +301,68 @@ export function LoginForm() {
|
||||
import { z } from 'zod';
|
||||
|
||||
// Email
|
||||
z.string().email('Invalid email address')
|
||||
z.string().email('Invalid email address');
|
||||
|
||||
// Min/max length
|
||||
z.string().min(8, 'Minimum 8 characters').max(100, 'Maximum 100 characters')
|
||||
z.string().min(8, 'Minimum 8 characters').max(100, 'Maximum 100 characters');
|
||||
|
||||
// Required field
|
||||
z.string().min(1, 'This field is required')
|
||||
z.string().min(1, 'This field is required');
|
||||
|
||||
// Optional field
|
||||
z.string().optional()
|
||||
z.string().optional();
|
||||
|
||||
// Number with range
|
||||
z.number().min(0).max(100)
|
||||
z.number().min(0).max(100);
|
||||
|
||||
// Number from string input
|
||||
z.coerce.number().min(0)
|
||||
z.coerce.number().min(0);
|
||||
|
||||
// Enum
|
||||
z.enum(['admin', 'user', 'guest'], {
|
||||
errorMap: () => ({ message: 'Invalid role' })
|
||||
})
|
||||
errorMap: () => ({ message: 'Invalid role' }),
|
||||
});
|
||||
|
||||
// URL
|
||||
z.string().url('Invalid URL')
|
||||
z.string().url('Invalid URL');
|
||||
|
||||
// Password with requirements
|
||||
z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number');
|
||||
|
||||
// Confirm password
|
||||
z.object({
|
||||
password: z.string().min(8),
|
||||
confirmPassword: z.string()
|
||||
confirmPassword: z.string(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
// Custom validation
|
||||
z.string().refine((val) => !val.includes('badword'), {
|
||||
message: 'Invalid input',
|
||||
})
|
||||
});
|
||||
|
||||
// Conditional fields
|
||||
z.object({
|
||||
role: z.enum(['admin', 'user']),
|
||||
adminKey: z.string().optional(),
|
||||
}).refine((data) => {
|
||||
if (data.role === 'admin') {
|
||||
return !!data.adminKey;
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.role === 'admin') {
|
||||
return !!data.adminKey;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Admin key required for admin role',
|
||||
path: ['adminKey'],
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
message: 'Admin key required for admin role',
|
||||
path: ['adminKey'],
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
@@ -448,6 +432,7 @@ type UserFormData = z.infer<typeof userFormSchema>;
|
||||
```
|
||||
|
||||
**Accessibility notes:**
|
||||
|
||||
- Use `aria-invalid` to indicate error state
|
||||
- Use `aria-describedby` to link error message
|
||||
- Error ID format: `{fieldName}-error`
|
||||
@@ -470,14 +455,14 @@ const onSubmit = async (data: FormData) => {
|
||||
};
|
||||
|
||||
// Display form-level error
|
||||
{form.formState.errors.root && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{form.formState.errors.root.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{
|
||||
form.formState.errors.root && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{form.formState.errors.root.message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -620,32 +605,26 @@ const onSubmit = async (data: FormData) => {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Personal Information</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Basic details about you
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Basic details about you</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
{/* Fields */}
|
||||
</div>
|
||||
<div className="space-y-4">{/* Fields */}</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2 */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Account Settings</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure your account preferences
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Configure your account preferences</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
{/* Fields */}
|
||||
</div>
|
||||
<div className="space-y-4">{/* Fields */}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button type="button" variant="outline">Cancel</Button>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -661,10 +640,14 @@ const onSubmit = async (data: FormData) => {
|
||||
import { useFieldArray } from 'react-hook-form';
|
||||
|
||||
const schema = z.object({
|
||||
items: z.array(z.object({
|
||||
name: z.string().min(1),
|
||||
quantity: z.coerce.number().min(1),
|
||||
})).min(1, 'At least one item required'),
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
quantity: z.coerce.number().min(1),
|
||||
})
|
||||
)
|
||||
.min(1, 'At least one item required'),
|
||||
});
|
||||
|
||||
function DynamicForm() {
|
||||
@@ -684,30 +667,19 @@ function DynamicForm() {
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-4">
|
||||
<Input
|
||||
{...form.register(`items.${index}.name`)}
|
||||
placeholder="Item name"
|
||||
/>
|
||||
<Input {...form.register(`items.${index}.name`)} placeholder="Item name" />
|
||||
<Input
|
||||
type="number"
|
||||
{...form.register(`items.${index}.quantity`)}
|
||||
placeholder="Quantity"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Button type="button" variant="destructive" onClick={() => remove(index)}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => append({ name: '', quantity: 1 })}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => append({ name: '', quantity: 1 })}>
|
||||
Add Item
|
||||
</Button>
|
||||
|
||||
@@ -722,18 +694,23 @@ function DynamicForm() {
|
||||
### Conditional Fields
|
||||
|
||||
```tsx
|
||||
const schema = z.object({
|
||||
role: z.enum(['user', 'admin']),
|
||||
adminKey: z.string().optional(),
|
||||
}).refine((data) => {
|
||||
if (data.role === 'admin') {
|
||||
return !!data.adminKey;
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
message: 'Admin key required',
|
||||
path: ['adminKey'],
|
||||
});
|
||||
const schema = z
|
||||
.object({
|
||||
role: z.enum(['user', 'admin']),
|
||||
adminKey: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.role === 'admin') {
|
||||
return !!data.adminKey;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Admin key required',
|
||||
path: ['adminKey'],
|
||||
}
|
||||
);
|
||||
|
||||
function ConditionalForm() {
|
||||
const form = useForm({ resolver: zodResolver(schema) });
|
||||
@@ -741,23 +718,17 @@ function ConditionalForm() {
|
||||
|
||||
return (
|
||||
<form className="space-y-4">
|
||||
<Select
|
||||
value={role}
|
||||
onValueChange={(val) => form.setValue('role', val as any)}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<Select value={role} onValueChange={(val) => form.setValue('role', val as any)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{role === 'admin' && (
|
||||
<Input
|
||||
{...form.register('adminKey')}
|
||||
placeholder="Admin Key"
|
||||
/>
|
||||
)}
|
||||
{role === 'admin' && <Input {...form.register('adminKey')} placeholder="Admin Key" />}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -774,14 +745,10 @@ const schema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
<input
|
||||
type="file"
|
||||
{...form.register('file')}
|
||||
accept="image/*"
|
||||
/>
|
||||
<input type="file" {...form.register('file')} accept="image/*" />;
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
const file = data.file[0]; // FileList -> File
|
||||
const file = data.file[0]; // FileList -> File
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
// Upload formData
|
||||
@@ -795,6 +762,7 @@ const onSubmit = (data: FormData) => {
|
||||
Before shipping a form, verify:
|
||||
|
||||
### Functionality
|
||||
|
||||
- [ ] All fields register correctly
|
||||
- [ ] Validation works (test invalid inputs)
|
||||
- [ ] Submit handler fires
|
||||
@@ -803,6 +771,7 @@ Before shipping a form, verify:
|
||||
- [ ] Success case redirects/shows success
|
||||
|
||||
### Accessibility
|
||||
|
||||
- [ ] Labels associated with inputs (`htmlFor` + `id`)
|
||||
- [ ] Error messages use `aria-describedby`
|
||||
- [ ] Invalid inputs have `aria-invalid`
|
||||
@@ -810,6 +779,7 @@ Before shipping a form, verify:
|
||||
- [ ] Submit button disabled during submission
|
||||
|
||||
### UX
|
||||
|
||||
- [ ] Field errors appear on blur or submit
|
||||
- [ ] Loading state prevents double-submit
|
||||
- [ ] Success message or redirect on success
|
||||
@@ -827,11 +797,13 @@ Before shipping a form, verify:
|
||||
---
|
||||
|
||||
**Related Documentation:**
|
||||
|
||||
- [Components](./02-components.md) - Input, Label, Button, Select
|
||||
- [Layouts](./03-layouts.md) - Form layout patterns
|
||||
- [Accessibility](./07-accessibility.md) - ARIA attributes for forms
|
||||
|
||||
**External Resources:**
|
||||
|
||||
- [react-hook-form Documentation](https://react-hook-form.com)
|
||||
- [Zod Documentation](https://zod.dev)
|
||||
|
||||
|
||||
@@ -24,12 +24,14 @@
|
||||
We follow **WCAG 2.1 Level AA** as the **minimum** standard.
|
||||
|
||||
**Why Level AA?**
|
||||
|
||||
- ✅ Required for most legal compliance (ADA, Section 508)
|
||||
- ✅ Covers 95%+ of accessibility needs
|
||||
- ✅ Achievable without major UX compromises
|
||||
- ✅ Industry standard for modern web apps
|
||||
|
||||
**WCAG Principles (POUR):**
|
||||
|
||||
1. **Perceivable** - Information can be perceived by users
|
||||
2. **Operable** - Interface can be operated by users
|
||||
3. **Understandable** - Information and operation are understandable
|
||||
@@ -63,14 +65,15 @@ Creating a UI element?
|
||||
|
||||
### Minimum Contrast Ratios (WCAG AA)
|
||||
|
||||
| Content Type | Minimum Ratio | Example |
|
||||
|--------------|---------------|---------|
|
||||
| **Normal text** (< 18px) | **4.5:1** | Body paragraphs, form labels |
|
||||
| **Large text** (≥ 18px or ≥ 14px bold) | **3:1** | Headings, subheadings |
|
||||
| **UI components** | **3:1** | Buttons, form borders, icons |
|
||||
| **Graphical objects** | **3:1** | Chart elements, infographics |
|
||||
| Content Type | Minimum Ratio | Example |
|
||||
| -------------------------------------- | ------------- | ---------------------------- |
|
||||
| **Normal text** (< 18px) | **4.5:1** | Body paragraphs, form labels |
|
||||
| **Large text** (≥ 18px or ≥ 14px bold) | **3:1** | Headings, subheadings |
|
||||
| **UI components** | **3:1** | Buttons, form borders, icons |
|
||||
| **Graphical objects** | **3:1** | Chart elements, infographics |
|
||||
|
||||
**WCAG AAA (ideal, not required):**
|
||||
|
||||
- Normal text: 7:1
|
||||
- Large text: 4.5:1
|
||||
|
||||
@@ -79,6 +82,7 @@ Creating a UI element?
|
||||
### Testing Color Contrast
|
||||
|
||||
**Tools:**
|
||||
|
||||
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
|
||||
- Chrome DevTools: Inspect element → Accessibility panel
|
||||
- [Contrast Ratio Tool](https://contrast-ratio.com)
|
||||
@@ -104,6 +108,7 @@ Creating a UI element?
|
||||
```
|
||||
|
||||
**Our design system tokens are WCAG AA compliant:**
|
||||
|
||||
- `text-foreground` on `bg-background`: 12.6:1 ✅
|
||||
- `text-primary-foreground` on `bg-primary`: 8.2:1 ✅
|
||||
- `text-destructive` on `bg-background`: 5.1:1 ✅
|
||||
@@ -116,6 +121,7 @@ Creating a UI element?
|
||||
**8% of men and 0.5% of women** have some form of color blindness.
|
||||
|
||||
**Best practices:**
|
||||
|
||||
- ❌ Don't rely on color alone to convey information
|
||||
- ✅ Use icons, text labels, or patterns in addition to color
|
||||
- ✅ Test with color blindness simulators
|
||||
@@ -148,6 +154,7 @@ Creating a UI element?
|
||||
### Core Requirements
|
||||
|
||||
All interactive elements must be:
|
||||
|
||||
1. ✅ **Focusable** - Can be reached with Tab key
|
||||
2. ✅ **Activatable** - Can be triggered with Enter or Space
|
||||
3. ✅ **Navigable** - Can move between with arrow keys (where appropriate)
|
||||
@@ -176,6 +183,7 @@ All interactive elements must be:
|
||||
```
|
||||
|
||||
**When to use `tabIndex`:**
|
||||
|
||||
- `tabIndex={0}` - Make non-interactive element focusable
|
||||
- `tabIndex={-1}` - Remove from tab order (for programmatic focus)
|
||||
- `tabIndex={1+}` - ❌ **Avoid** - Breaks natural order
|
||||
@@ -184,31 +192,31 @@ All interactive elements must be:
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Key | Action | Example |
|
||||
|-----|--------|---------|
|
||||
| **Tab** | Move focus forward | Navigate through form fields |
|
||||
| **Shift + Tab** | Move focus backward | Go back to previous field |
|
||||
| **Enter** | Activate button/link | Submit form, follow link |
|
||||
| **Space** | Activate button/checkbox | Toggle checkbox, click button |
|
||||
| **Escape** | Close overlay | Close dialog, dropdown |
|
||||
| **Arrow keys** | Navigate within component | Navigate dropdown items |
|
||||
| **Home** | Jump to start | First item in list |
|
||||
| **End** | Jump to end | Last item in list |
|
||||
| Key | Action | Example |
|
||||
| --------------- | ------------------------- | ----------------------------- |
|
||||
| **Tab** | Move focus forward | Navigate through form fields |
|
||||
| **Shift + Tab** | Move focus backward | Go back to previous field |
|
||||
| **Enter** | Activate button/link | Submit form, follow link |
|
||||
| **Space** | Activate button/checkbox | Toggle checkbox, click button |
|
||||
| **Escape** | Close overlay | Close dialog, dropdown |
|
||||
| **Arrow keys** | Navigate within component | Navigate dropdown items |
|
||||
| **Home** | Jump to start | First item in list |
|
||||
| **End** | Jump to end | Last item in list |
|
||||
|
||||
---
|
||||
|
||||
### Implementing Keyboard Navigation
|
||||
|
||||
**Button (automatic):**
|
||||
|
||||
```tsx
|
||||
// ✅ Button is keyboard accessible by default
|
||||
<Button onClick={handleClick}>
|
||||
Click me
|
||||
</Button>
|
||||
<Button onClick={handleClick}>Click me</Button>
|
||||
// Enter or Space triggers onClick
|
||||
```
|
||||
|
||||
**Custom clickable div (needs work):**
|
||||
|
||||
```tsx
|
||||
// ❌ BAD - Not keyboard accessible
|
||||
<div onClick={handleClick}>
|
||||
@@ -237,11 +245,12 @@ All interactive elements must be:
|
||||
```
|
||||
|
||||
**Dropdown navigation:**
|
||||
|
||||
```tsx
|
||||
<DropdownMenu>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem> {/* Arrow down */}
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem> {/* Arrow down */}
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem> {/* Arrow down */}
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem> {/* Arrow down */}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
// shadcn/ui handles arrow key navigation automatically
|
||||
@@ -276,12 +285,14 @@ All interactive elements must be:
|
||||
### Screen Reader Basics
|
||||
|
||||
**Popular screen readers:**
|
||||
|
||||
- **NVDA** (Windows) - Free, most popular for testing
|
||||
- **JAWS** (Windows) - Industry standard, paid
|
||||
- **VoiceOver** (macOS/iOS) - Built-in to Apple devices
|
||||
- **TalkBack** (Android) - Built-in to Android
|
||||
|
||||
**What screen readers announce:**
|
||||
|
||||
- Semantic element type (button, link, heading, etc.)
|
||||
- Element text content
|
||||
- Element state (expanded, selected, disabled)
|
||||
@@ -334,6 +345,7 @@ All interactive elements must be:
|
||||
```
|
||||
|
||||
**Semantic elements:**
|
||||
|
||||
- `<header>` - Page header
|
||||
- `<nav>` - Navigation
|
||||
- `<main>` - Main content (only one per page)
|
||||
@@ -364,6 +376,7 @@ All interactive elements must be:
|
||||
```
|
||||
|
||||
**Icon-only buttons:**
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD - ARIA label
|
||||
<Button size="icon" aria-label="Close dialog">
|
||||
@@ -383,6 +396,7 @@ All interactive elements must be:
|
||||
### Common ARIA Attributes
|
||||
|
||||
**ARIA roles:**
|
||||
|
||||
```tsx
|
||||
<div role="button" tabIndex={0}>Custom Button</div>
|
||||
<div role="alert">Error message</div>
|
||||
@@ -391,6 +405,7 @@ All interactive elements must be:
|
||||
```
|
||||
|
||||
**ARIA states:**
|
||||
|
||||
```tsx
|
||||
<button aria-expanded={isOpen}>Toggle Menu</button>
|
||||
<button aria-pressed={isActive}>Toggle</button>
|
||||
@@ -399,6 +414,7 @@ All interactive elements must be:
|
||||
```
|
||||
|
||||
**ARIA properties:**
|
||||
|
||||
```tsx
|
||||
<button aria-label="Close">×</button>
|
||||
<input aria-describedby="email-help" />
|
||||
@@ -412,6 +428,7 @@ All interactive elements must be:
|
||||
### Form Accessibility
|
||||
|
||||
**Label association:**
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD - Explicit association
|
||||
<Label htmlFor="email">Email</Label>
|
||||
@@ -423,6 +440,7 @@ All interactive elements must be:
|
||||
```
|
||||
|
||||
**Error messages:**
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD - Linked with aria-describedby
|
||||
<Label htmlFor="password">Password</Label>
|
||||
@@ -444,6 +462,7 @@ All interactive elements must be:
|
||||
```
|
||||
|
||||
**Required fields:**
|
||||
|
||||
```tsx
|
||||
// ✅ GOOD - Marked as required
|
||||
<Label htmlFor="name">
|
||||
@@ -502,6 +521,7 @@ toast.success('User created');
|
||||
```
|
||||
|
||||
**Use `:focus-visible` instead of `:focus`:**
|
||||
|
||||
- `:focus` - Shows on mouse click AND keyboard
|
||||
- `:focus-visible` - Shows only on keyboard (better UX)
|
||||
|
||||
@@ -516,7 +536,7 @@ toast.success('User created');
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
{/* Focus trapped inside */}
|
||||
<Input autoFocus /> {/* Focus first field */}
|
||||
<Input autoFocus /> {/* Focus first field */}
|
||||
<Button>Submit</Button>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -539,7 +559,7 @@ const handleDelete = () => {
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
<Input ref={inputRef} />
|
||||
<Input ref={inputRef} />;
|
||||
```
|
||||
|
||||
---
|
||||
@@ -549,11 +569,13 @@ const handleDelete = () => {
|
||||
### Automated Testing Tools
|
||||
|
||||
**Browser extensions:**
|
||||
|
||||
- [axe DevTools](https://www.deque.com/axe/devtools/) - Free, comprehensive
|
||||
- [WAVE](https://wave.webaim.org/extension/) - Visual feedback
|
||||
- [Lighthouse](https://developer.chrome.com/docs/lighthouse/) - Built into Chrome
|
||||
|
||||
**CI/CD testing:**
|
||||
|
||||
- [@axe-core/react](https://github.com/dequelabs/axe-core-npm/tree/develop/packages/react) - Runtime accessibility testing
|
||||
- [jest-axe](https://github.com/nickcolley/jest-axe) - Jest integration
|
||||
- [Playwright accessibility testing](https://playwright.dev/docs/accessibility-testing)
|
||||
@@ -563,6 +585,7 @@ const handleDelete = () => {
|
||||
### Manual Testing Checklist
|
||||
|
||||
#### Keyboard Testing
|
||||
|
||||
1. [ ] Unplug mouse
|
||||
2. [ ] Tab through entire page
|
||||
3. [ ] All interactive elements focusable?
|
||||
@@ -572,6 +595,7 @@ const handleDelete = () => {
|
||||
7. [ ] Tab order logical?
|
||||
|
||||
#### Screen Reader Testing
|
||||
|
||||
1. [ ] Install NVDA (Windows) or VoiceOver (Mac)
|
||||
2. [ ] Navigate page with screen reader on
|
||||
3. [ ] All content announced?
|
||||
@@ -580,6 +604,7 @@ const handleDelete = () => {
|
||||
6. [ ] Heading hierarchy correct?
|
||||
|
||||
#### Contrast Testing
|
||||
|
||||
1. [ ] Use contrast checker on all text
|
||||
2. [ ] Check UI components (buttons, borders)
|
||||
3. [ ] Test in dark mode too
|
||||
@@ -590,6 +615,7 @@ const handleDelete = () => {
|
||||
### Testing with Real Users
|
||||
|
||||
**Considerations:**
|
||||
|
||||
- Test with actual users who rely on assistive technologies
|
||||
- Different screen readers behave differently
|
||||
- Mobile screen readers (VoiceOver, TalkBack) differ from desktop
|
||||
@@ -600,6 +626,7 @@ const handleDelete = () => {
|
||||
## Accessibility Checklist
|
||||
|
||||
### General
|
||||
|
||||
- [ ] Page has `<title>` and `<meta name="description">`
|
||||
- [ ] Page has proper heading hierarchy (h1 → h2 → h3)
|
||||
- [ ] Landmarks used (`<header>`, `<nav>`, `<main>`, `<footer>`)
|
||||
@@ -607,12 +634,14 @@ const handleDelete = () => {
|
||||
- [ ] No content relies on color alone
|
||||
|
||||
### Color & Contrast
|
||||
|
||||
- [ ] Text has 4.5:1 contrast (normal) or 3:1 (large)
|
||||
- [ ] UI components have 3:1 contrast
|
||||
- [ ] Tested in both light and dark modes
|
||||
- [ ] Color blindness simulator used
|
||||
|
||||
### Keyboard
|
||||
|
||||
- [ ] All interactive elements focusable
|
||||
- [ ] Focus indicators visible (ring, outline, etc.)
|
||||
- [ ] Tab order is logical
|
||||
@@ -622,6 +651,7 @@ const handleDelete = () => {
|
||||
- [ ] Arrow keys navigate lists/menus
|
||||
|
||||
### Screen Readers
|
||||
|
||||
- [ ] All images have alt text
|
||||
- [ ] Icon-only buttons have aria-label
|
||||
- [ ] Form labels associated with inputs
|
||||
@@ -631,6 +661,7 @@ const handleDelete = () => {
|
||||
- [ ] ARIA roles used correctly
|
||||
|
||||
### Forms
|
||||
|
||||
- [ ] Labels associated with inputs (`htmlFor` + `id`)
|
||||
- [ ] Error messages linked (`aria-describedby`)
|
||||
- [ ] Invalid inputs marked (`aria-invalid`)
|
||||
@@ -638,6 +669,7 @@ const handleDelete = () => {
|
||||
- [ ] Submit button disabled during submission
|
||||
|
||||
### Focus Management
|
||||
|
||||
- [ ] Dialogs trap focus
|
||||
- [ ] Focus returns after dialog closes
|
||||
- [ ] Programmatic focus after actions
|
||||
@@ -650,29 +682,36 @@ const handleDelete = () => {
|
||||
**Easy improvements with big impact:**
|
||||
|
||||
1. **Add alt text to images**
|
||||
|
||||
```tsx
|
||||
<img src="/logo.png" alt="Company Logo" />
|
||||
```
|
||||
|
||||
2. **Associate labels with inputs**
|
||||
|
||||
```tsx
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input id="email" />
|
||||
```
|
||||
|
||||
3. **Use semantic HTML**
|
||||
|
||||
```tsx
|
||||
<button> instead of <div onClick>
|
||||
```
|
||||
|
||||
4. **Add aria-label to icon buttons**
|
||||
|
||||
```tsx
|
||||
<Button aria-label="Close"><X /></Button>
|
||||
<Button aria-label="Close">
|
||||
<X />
|
||||
</Button>
|
||||
```
|
||||
|
||||
5. **Use semantic color tokens**
|
||||
|
||||
```tsx
|
||||
className="text-foreground" // Auto contrast
|
||||
className = 'text-foreground'; // Auto contrast
|
||||
```
|
||||
|
||||
6. **Test with keyboard only**
|
||||
@@ -691,11 +730,13 @@ const handleDelete = () => {
|
||||
---
|
||||
|
||||
**Related Documentation:**
|
||||
|
||||
- [Forms](./06-forms.md) - Accessible form patterns
|
||||
- [Components](./02-components.md) - All components are accessible
|
||||
- [Foundations](./01-foundations.md) - Color contrast tokens
|
||||
|
||||
**External Resources:**
|
||||
|
||||
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
|
||||
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
|
||||
- [A11y Project Checklist](https://www.a11yproject.com/checklist/)
|
||||
|
||||
@@ -9,19 +9,22 @@
|
||||
### ALWAYS Do
|
||||
|
||||
1. ✅ **Import from `@/components/ui/*`**
|
||||
|
||||
```tsx
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||
```
|
||||
|
||||
2. ✅ **Use semantic color tokens**
|
||||
|
||||
```tsx
|
||||
className="bg-primary text-primary-foreground"
|
||||
className="text-destructive"
|
||||
className="bg-muted text-muted-foreground"
|
||||
className = 'bg-primary text-primary-foreground';
|
||||
className = 'text-destructive';
|
||||
className = 'bg-muted text-muted-foreground';
|
||||
```
|
||||
|
||||
3. ✅ **Use `cn()` utility for className merging**
|
||||
|
||||
```tsx
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -29,11 +32,13 @@
|
||||
```
|
||||
|
||||
4. ✅ **Follow spacing scale** (multiples of 4: 0, 1, 2, 3, 4, 6, 8, 12, 16)
|
||||
|
||||
```tsx
|
||||
className="p-4 space-y-6 mb-8"
|
||||
className = 'p-4 space-y-6 mb-8';
|
||||
```
|
||||
|
||||
5. ✅ **Add accessibility attributes**
|
||||
|
||||
```tsx
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
@@ -44,12 +49,14 @@
|
||||
```
|
||||
|
||||
6. ✅ **Use component variants**
|
||||
|
||||
```tsx
|
||||
<Button variant="destructive">Delete</Button>
|
||||
<Alert variant="destructive">Error message</Alert>
|
||||
```
|
||||
|
||||
7. ✅ **Compose from shadcn/ui primitives**
|
||||
|
||||
```tsx
|
||||
// Don't create custom card components
|
||||
// Use Card + CardHeader + CardTitle + CardContent
|
||||
@@ -57,8 +64,8 @@
|
||||
|
||||
8. ✅ **Use mobile-first responsive design**
|
||||
```tsx
|
||||
className="text-2xl sm:text-3xl lg:text-4xl"
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
|
||||
className = 'text-2xl sm:text-3xl lg:text-4xl';
|
||||
className = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3';
|
||||
```
|
||||
|
||||
---
|
||||
@@ -66,24 +73,27 @@
|
||||
### NEVER Do
|
||||
|
||||
1. ❌ **NO arbitrary colors**
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG
|
||||
className="bg-blue-500 text-white"
|
||||
className = 'bg-blue-500 text-white';
|
||||
|
||||
// ✅ CORRECT
|
||||
className="bg-primary text-primary-foreground"
|
||||
className = 'bg-primary text-primary-foreground';
|
||||
```
|
||||
|
||||
2. ❌ **NO arbitrary spacing values**
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG
|
||||
className="p-[13px] mb-[17px]"
|
||||
className = 'p-[13px] mb-[17px]';
|
||||
|
||||
// ✅ CORRECT
|
||||
className="p-4 mb-4"
|
||||
className = 'p-4 mb-4';
|
||||
```
|
||||
|
||||
3. ❌ **NO inline styles**
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG
|
||||
style={{ margin: '10px', color: '#3b82f6' }}
|
||||
@@ -93,6 +103,7 @@
|
||||
```
|
||||
|
||||
4. ❌ **NO custom CSS classes** (use Tailwind utilities)
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG
|
||||
<div className="my-custom-class">
|
||||
@@ -102,6 +113,7 @@
|
||||
```
|
||||
|
||||
5. ❌ **NO mixing component libraries**
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG - Don't use Material-UI, Ant Design, etc.
|
||||
import { Button } from '@mui/material';
|
||||
@@ -111,6 +123,7 @@
|
||||
```
|
||||
|
||||
6. ❌ **NO skipping accessibility**
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG
|
||||
<button><X /></button>
|
||||
@@ -122,6 +135,7 @@
|
||||
```
|
||||
|
||||
7. ❌ **NO creating custom variants without CVA**
|
||||
|
||||
```tsx
|
||||
// ❌ WRONG
|
||||
<Button className={type === 'danger' ? 'bg-red-500' : 'bg-blue-500'}>
|
||||
@@ -138,9 +152,7 @@
|
||||
|
||||
```tsx
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Content */}
|
||||
</div>
|
||||
<div className="max-w-4xl mx-auto space-y-6">{/* Content */}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
@@ -148,7 +160,9 @@
|
||||
|
||||
```tsx
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{items.map(item => <Card key={item.id}>...</Card>)}
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>...</Card>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
@@ -160,9 +174,7 @@
|
||||
<CardTitle>Form Title</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4">
|
||||
{/* Form fields */}
|
||||
</form>
|
||||
<form className="space-y-4">{/* Form fields */}</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
@@ -170,9 +182,7 @@
|
||||
### Centered Content
|
||||
|
||||
```tsx
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
{/* Readable content width */}
|
||||
</div>
|
||||
<div className="max-w-2xl mx-auto px-4">{/* Readable content width */}</div>
|
||||
```
|
||||
|
||||
---
|
||||
@@ -191,17 +201,15 @@ interface MyComponentProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MyComponent({
|
||||
variant = 'default',
|
||||
className,
|
||||
children
|
||||
}: MyComponentProps) {
|
||||
export function MyComponent({ variant = 'default', className, children }: MyComponentProps) {
|
||||
return (
|
||||
<Card className={cn(
|
||||
"p-4", // base styles
|
||||
variant === 'compact' && "p-2",
|
||||
className // allow overrides
|
||||
)}>
|
||||
<Card
|
||||
className={cn(
|
||||
'p-4', // base styles
|
||||
variant === 'compact' && 'p-2',
|
||||
className // allow overrides
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
@@ -215,22 +223,22 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const componentVariants = cva(
|
||||
"base-classes-here", // base
|
||||
'base-classes-here', // base
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground",
|
||||
destructive: "bg-destructive text-destructive-foreground",
|
||||
default: 'bg-primary text-primary-foreground',
|
||||
destructive: 'bg-destructive text-destructive-foreground',
|
||||
},
|
||||
size: {
|
||||
sm: "h-8 px-3 text-xs",
|
||||
default: "h-10 px-4 text-sm",
|
||||
lg: "h-12 px-6 text-base",
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
default: 'h-10 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -240,9 +248,7 @@ interface ComponentProps
|
||||
VariantProps<typeof componentVariants> {}
|
||||
|
||||
export function Component({ variant, size, className, ...props }: ComponentProps) {
|
||||
return (
|
||||
<div className={cn(componentVariants({ variant, size, className }))} {...props} />
|
||||
);
|
||||
return <div className={cn(componentVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -313,18 +319,18 @@ export function MyForm() {
|
||||
|
||||
**Always use these semantic tokens:**
|
||||
|
||||
| Token | Usage |
|
||||
|-------|-------|
|
||||
| `bg-primary text-primary-foreground` | Primary buttons, CTAs |
|
||||
| `bg-secondary text-secondary-foreground` | Secondary actions |
|
||||
| `bg-destructive text-destructive-foreground` | Delete, errors |
|
||||
| `bg-muted text-muted-foreground` | Disabled states |
|
||||
| `bg-accent text-accent-foreground` | Hover states |
|
||||
| `bg-card text-card-foreground` | Card backgrounds |
|
||||
| `text-foreground` | Body text |
|
||||
| `text-muted-foreground` | Secondary text |
|
||||
| `border-border` | Borders |
|
||||
| `ring-ring` | Focus rings |
|
||||
| Token | Usage |
|
||||
| -------------------------------------------- | --------------------- |
|
||||
| `bg-primary text-primary-foreground` | Primary buttons, CTAs |
|
||||
| `bg-secondary text-secondary-foreground` | Secondary actions |
|
||||
| `bg-destructive text-destructive-foreground` | Delete, errors |
|
||||
| `bg-muted text-muted-foreground` | Disabled states |
|
||||
| `bg-accent text-accent-foreground` | Hover states |
|
||||
| `bg-card text-card-foreground` | Card backgrounds |
|
||||
| `text-foreground` | Body text |
|
||||
| `text-muted-foreground` | Secondary text |
|
||||
| `border-border` | Borders |
|
||||
| `ring-ring` | Focus rings |
|
||||
|
||||
---
|
||||
|
||||
@@ -332,14 +338,14 @@ export function MyForm() {
|
||||
|
||||
**Use these spacing values (multiples of 4px):**
|
||||
|
||||
| Class | Value | Pixels | Usage |
|
||||
|-------|-------|--------|-------|
|
||||
| `2` | 0.5rem | 8px | Tight spacing |
|
||||
| `4` | 1rem | 16px | Standard spacing |
|
||||
| `6` | 1.5rem | 24px | Section spacing |
|
||||
| `8` | 2rem | 32px | Large gaps |
|
||||
| `12` | 3rem | 48px | Section dividers |
|
||||
| `16` | 4rem | 64px | Page sections |
|
||||
| Class | Value | Pixels | Usage |
|
||||
| ----- | ------ | ------ | ---------------- |
|
||||
| `2` | 0.5rem | 8px | Tight spacing |
|
||||
| `4` | 1rem | 16px | Standard spacing |
|
||||
| `6` | 1.5rem | 24px | Section spacing |
|
||||
| `8` | 2rem | 32px | Large gaps |
|
||||
| `12` | 3rem | 48px | Section dividers |
|
||||
| `16` | 4rem | 64px | Page sections |
|
||||
|
||||
---
|
||||
|
||||
@@ -428,7 +434,7 @@ function MyCard({ title, children }) {
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
</Card>;
|
||||
```
|
||||
|
||||
### ❌ Mistake 5: Not using cn() utility
|
||||
@@ -497,7 +503,7 @@ Add to `.github/copilot-instructions.md`:
|
||||
```markdown
|
||||
# Component Guidelines
|
||||
|
||||
- Import from @/components/ui/*
|
||||
- Import from @/components/ui/\*
|
||||
- Use semantic colors: bg-primary, text-destructive
|
||||
- Spacing: multiples of 4 (p-4, mb-6, gap-8)
|
||||
- Use cn() for className merging
|
||||
@@ -525,20 +531,20 @@ interface DashboardCardProps {
|
||||
|
||||
export function DashboardCard({ title, value, trend, className }: DashboardCardProps) {
|
||||
return (
|
||||
<Card className={cn("p-6", className)}>
|
||||
<Card className={cn('p-6', className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
{trend && (
|
||||
<p className={cn(
|
||||
"text-xs",
|
||||
trend === 'up' && "text-green-600",
|
||||
trend === 'down' && "text-destructive"
|
||||
)}>
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs',
|
||||
trend === 'up' && 'text-green-600',
|
||||
trend === 'down' && 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{trend === 'up' ? '↑' : '↓'} Trend
|
||||
</p>
|
||||
)}
|
||||
@@ -549,6 +555,7 @@ export function DashboardCard({ title, value, trend, className }: DashboardCardP
|
||||
```
|
||||
|
||||
**Why it's good:**
|
||||
|
||||
- ✅ Imports from `@/components/ui/*`
|
||||
- ✅ Uses semantic tokens
|
||||
- ✅ Uses `cn()` utility
|
||||
|
||||
@@ -20,21 +20,21 @@
|
||||
|
||||
### Semantic Colors
|
||||
|
||||
| Token | Usage | Example |
|
||||
|-------|-------|---------|
|
||||
| `bg-primary text-primary-foreground` | CTAs, primary actions | Primary button |
|
||||
| `bg-secondary text-secondary-foreground` | Secondary actions | Secondary button |
|
||||
| `bg-destructive text-destructive-foreground` | Delete, errors | Delete button, error alert |
|
||||
| `bg-muted text-muted-foreground` | Disabled, subtle | Disabled button, TabsList |
|
||||
| `bg-accent text-accent-foreground` | Hover states | Dropdown hover |
|
||||
| `bg-card text-card-foreground` | Cards, elevated surfaces | Card component |
|
||||
| `bg-popover text-popover-foreground` | Popovers, dropdowns | Dropdown content |
|
||||
| `bg-background text-foreground` | Page background | Body |
|
||||
| `text-foreground` | Body text | Paragraphs |
|
||||
| `text-muted-foreground` | Secondary text | Captions, helper text |
|
||||
| `border-border` | Borders, dividers | Card borders, separators |
|
||||
| `border-input` | Input borders | Text input border |
|
||||
| `ring-ring` | Focus indicators | Focus ring |
|
||||
| Token | Usage | Example |
|
||||
| -------------------------------------------- | ------------------------ | -------------------------- |
|
||||
| `bg-primary text-primary-foreground` | CTAs, primary actions | Primary button |
|
||||
| `bg-secondary text-secondary-foreground` | Secondary actions | Secondary button |
|
||||
| `bg-destructive text-destructive-foreground` | Delete, errors | Delete button, error alert |
|
||||
| `bg-muted text-muted-foreground` | Disabled, subtle | Disabled button, TabsList |
|
||||
| `bg-accent text-accent-foreground` | Hover states | Dropdown hover |
|
||||
| `bg-card text-card-foreground` | Cards, elevated surfaces | Card component |
|
||||
| `bg-popover text-popover-foreground` | Popovers, dropdowns | Dropdown content |
|
||||
| `bg-background text-foreground` | Page background | Body |
|
||||
| `text-foreground` | Body text | Paragraphs |
|
||||
| `text-muted-foreground` | Secondary text | Captions, helper text |
|
||||
| `border-border` | Borders, dividers | Card borders, separators |
|
||||
| `border-input` | Input borders | Text input border |
|
||||
| `ring-ring` | Focus indicators | Focus ring |
|
||||
|
||||
### Usage Examples
|
||||
|
||||
@@ -61,29 +61,29 @@
|
||||
|
||||
### Font Sizes
|
||||
|
||||
| Class | rem | px | Use Case | Common |
|
||||
|-------|-----|----|----|:------:|
|
||||
| `text-xs` | 0.75rem | 12px | Labels, fine print | |
|
||||
| `text-sm` | 0.875rem | 14px | Secondary text, captions | ⭐ |
|
||||
| `text-base` | 1rem | 16px | Body text (default) | ⭐ |
|
||||
| `text-lg` | 1.125rem | 18px | Subheadings | |
|
||||
| `text-xl` | 1.25rem | 20px | Card titles | ⭐ |
|
||||
| `text-2xl` | 1.5rem | 24px | Section headings | ⭐ |
|
||||
| `text-3xl` | 1.875rem | 30px | Page titles | ⭐ |
|
||||
| `text-4xl` | 2.25rem | 36px | Large headings | |
|
||||
| `text-5xl` | 3rem | 48px | Hero text | |
|
||||
| Class | rem | px | Use Case | Common |
|
||||
| ----------- | -------- | ---- | ------------------------ | :----: |
|
||||
| `text-xs` | 0.75rem | 12px | Labels, fine print | |
|
||||
| `text-sm` | 0.875rem | 14px | Secondary text, captions | ⭐ |
|
||||
| `text-base` | 1rem | 16px | Body text (default) | ⭐ |
|
||||
| `text-lg` | 1.125rem | 18px | Subheadings | |
|
||||
| `text-xl` | 1.25rem | 20px | Card titles | ⭐ |
|
||||
| `text-2xl` | 1.5rem | 24px | Section headings | ⭐ |
|
||||
| `text-3xl` | 1.875rem | 30px | Page titles | ⭐ |
|
||||
| `text-4xl` | 2.25rem | 36px | Large headings | |
|
||||
| `text-5xl` | 3rem | 48px | Hero text | |
|
||||
|
||||
⭐ = Most commonly used
|
||||
|
||||
### Font Weights
|
||||
|
||||
| Class | Value | Use Case | Common |
|
||||
|-------|-------|----------|:------:|
|
||||
| `font-light` | 300 | De-emphasized text | |
|
||||
| `font-normal` | 400 | Body text (default) | ⭐ |
|
||||
| `font-medium` | 500 | Labels, menu items | ⭐ |
|
||||
| `font-semibold` | 600 | Subheadings, buttons | ⭐ |
|
||||
| `font-bold` | 700 | Headings, emphasis | ⭐ |
|
||||
| Class | Value | Use Case | Common |
|
||||
| --------------- | ----- | -------------------- | :----: |
|
||||
| `font-light` | 300 | De-emphasized text | |
|
||||
| `font-normal` | 400 | Body text (default) | ⭐ |
|
||||
| `font-medium` | 500 | Labels, menu items | ⭐ |
|
||||
| `font-semibold` | 600 | Subheadings, buttons | ⭐ |
|
||||
| `font-bold` | 700 | Headings, emphasis | ⭐ |
|
||||
|
||||
⭐ = Most commonly used
|
||||
|
||||
@@ -115,35 +115,35 @@
|
||||
|
||||
### Spacing Values
|
||||
|
||||
| Token | rem | px | Use Case | Common |
|
||||
|-------|-----|----|----|:------:|
|
||||
| `0` | 0 | 0px | No spacing | |
|
||||
| `px` | - | 1px | Borders | |
|
||||
| `0.5` | 0.125rem | 2px | Very tight | |
|
||||
| `1` | 0.25rem | 4px | Icon gaps | |
|
||||
| `2` | 0.5rem | 8px | Tight spacing (label → input) | ⭐ |
|
||||
| `3` | 0.75rem | 12px | Component padding | |
|
||||
| `4` | 1rem | 16px | Standard spacing (form fields) | ⭐ |
|
||||
| `5` | 1.25rem | 20px | Medium spacing | |
|
||||
| `6` | 1.5rem | 24px | Section spacing (cards) | ⭐ |
|
||||
| `8` | 2rem | 32px | Large gaps | ⭐ |
|
||||
| `10` | 2.5rem | 40px | Very large gaps | |
|
||||
| `12` | 3rem | 48px | Section dividers | ⭐ |
|
||||
| `16` | 4rem | 64px | Page sections | |
|
||||
| Token | rem | px | Use Case | Common |
|
||||
| ----- | -------- | ---- | ------------------------------ | :----: |
|
||||
| `0` | 0 | 0px | No spacing | |
|
||||
| `px` | - | 1px | Borders | |
|
||||
| `0.5` | 0.125rem | 2px | Very tight | |
|
||||
| `1` | 0.25rem | 4px | Icon gaps | |
|
||||
| `2` | 0.5rem | 8px | Tight spacing (label → input) | ⭐ |
|
||||
| `3` | 0.75rem | 12px | Component padding | |
|
||||
| `4` | 1rem | 16px | Standard spacing (form fields) | ⭐ |
|
||||
| `5` | 1.25rem | 20px | Medium spacing | |
|
||||
| `6` | 1.5rem | 24px | Section spacing (cards) | ⭐ |
|
||||
| `8` | 2rem | 32px | Large gaps | ⭐ |
|
||||
| `10` | 2.5rem | 40px | Very large gaps | |
|
||||
| `12` | 3rem | 48px | Section dividers | ⭐ |
|
||||
| `16` | 4rem | 64px | Page sections | |
|
||||
|
||||
⭐ = Most commonly used
|
||||
|
||||
### Spacing Methods
|
||||
|
||||
| Method | Use Case | Example |
|
||||
|--------|----------|---------|
|
||||
| `gap-4` | Flex/grid spacing | `flex gap-4` |
|
||||
| `space-y-4` | Vertical stack spacing | `space-y-4` |
|
||||
| `space-x-4` | Horizontal stack spacing | `space-x-4` |
|
||||
| `p-4` | Padding (all sides) | `p-4` |
|
||||
| `px-4` | Horizontal padding | `px-4` |
|
||||
| `py-4` | Vertical padding | `py-4` |
|
||||
| `m-4` | Margin (exceptions only!) | `mt-8` |
|
||||
| Method | Use Case | Example |
|
||||
| ----------- | ------------------------- | ------------ |
|
||||
| `gap-4` | Flex/grid spacing | `flex gap-4` |
|
||||
| `space-y-4` | Vertical stack spacing | `space-y-4` |
|
||||
| `space-x-4` | Horizontal stack spacing | `space-x-4` |
|
||||
| `p-4` | Padding (all sides) | `p-4` |
|
||||
| `px-4` | Horizontal padding | `px-4` |
|
||||
| `py-4` | Vertical padding | `py-4` |
|
||||
| `m-4` | Margin (exceptions only!) | `mt-8` |
|
||||
|
||||
### Common Spacing Patterns
|
||||
|
||||
@@ -217,52 +217,52 @@
|
||||
|
||||
```tsx
|
||||
// 1 → 2 → 3 progression (most common)
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
className = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6';
|
||||
|
||||
// 1 → 2 → 4 progression
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"
|
||||
className = 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6';
|
||||
|
||||
// 1 → 2 progression (simple)
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-6"
|
||||
className = 'grid grid-cols-1 md:grid-cols-2 gap-6';
|
||||
|
||||
// 1 → 3 progression (skip 2)
|
||||
className="grid grid-cols-1 lg:grid-cols-3 gap-6"
|
||||
className = 'grid grid-cols-1 lg:grid-cols-3 gap-6';
|
||||
```
|
||||
|
||||
### Container Widths
|
||||
|
||||
```tsx
|
||||
// Standard container
|
||||
className="container mx-auto px-4"
|
||||
className = 'container mx-auto px-4';
|
||||
|
||||
// Constrained widths
|
||||
className="max-w-md mx-auto" // 448px - Forms
|
||||
className="max-w-lg mx-auto" // 512px - Modals
|
||||
className="max-w-2xl mx-auto" // 672px - Articles
|
||||
className="max-w-4xl mx-auto" // 896px - Wide layouts
|
||||
className="max-w-7xl mx-auto" // 1280px - Full page
|
||||
className = 'max-w-md mx-auto'; // 448px - Forms
|
||||
className = 'max-w-lg mx-auto'; // 512px - Modals
|
||||
className = 'max-w-2xl mx-auto'; // 672px - Articles
|
||||
className = 'max-w-4xl mx-auto'; // 896px - Wide layouts
|
||||
className = 'max-w-7xl mx-auto'; // 1280px - Full page
|
||||
```
|
||||
|
||||
### Flex Patterns
|
||||
|
||||
```tsx
|
||||
// Horizontal flex
|
||||
className="flex gap-4"
|
||||
className = 'flex gap-4';
|
||||
|
||||
// Vertical flex
|
||||
className="flex flex-col gap-4"
|
||||
className = 'flex flex-col gap-4';
|
||||
|
||||
// Center items
|
||||
className="flex items-center justify-center"
|
||||
className = 'flex items-center justify-center';
|
||||
|
||||
// Space between
|
||||
className="flex items-center justify-between"
|
||||
className = 'flex items-center justify-between';
|
||||
|
||||
// Wrap items
|
||||
className="flex flex-wrap gap-4"
|
||||
className = 'flex flex-wrap gap-4';
|
||||
|
||||
// Responsive: stack on mobile, row on desktop
|
||||
className="flex flex-col sm:flex-row gap-4"
|
||||
className = 'flex flex-col sm:flex-row gap-4';
|
||||
```
|
||||
|
||||
---
|
||||
@@ -273,9 +273,7 @@ className="flex flex-col sm:flex-row gap-4"
|
||||
|
||||
```tsx
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Content */}
|
||||
</div>
|
||||
<div className="max-w-4xl mx-auto space-y-6">{/* Content */}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
@@ -287,7 +285,9 @@ className="flex flex-col sm:flex-row gap-4"
|
||||
<CardTitle>Title</CardTitle>
|
||||
<CardDescription>Description</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm">Action</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
Action
|
||||
</Button>
|
||||
</CardHeader>
|
||||
```
|
||||
|
||||
@@ -319,9 +319,7 @@ className="flex flex-col sm:flex-row gap-4"
|
||||
<CardTitle>Form Title</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4">
|
||||
{/* Fields */}
|
||||
</form>
|
||||
<form className="space-y-4">{/* Fields */}</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -354,17 +352,13 @@ className="flex flex-col sm:flex-row gap-4"
|
||||
### Responsive Text
|
||||
|
||||
```tsx
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold">
|
||||
Responsive Title
|
||||
</h1>
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold">Responsive Title</h1>
|
||||
```
|
||||
|
||||
### Responsive Padding
|
||||
|
||||
```tsx
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
Responsive padding
|
||||
</div>
|
||||
<div className="p-4 sm:p-6 lg:p-8">Responsive padding</div>
|
||||
```
|
||||
|
||||
---
|
||||
@@ -427,16 +421,16 @@ Has error?
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action | Context |
|
||||
|-----|--------|---------|
|
||||
| `Tab` | Move focus forward | All |
|
||||
| `Shift + Tab` | Move focus backward | All |
|
||||
| `Enter` | Activate button/link | Buttons, links |
|
||||
| `Space` | Activate button/checkbox | Buttons, checkboxes |
|
||||
| `Escape` | Close overlay | Dialogs, dropdowns |
|
||||
| `Arrow keys` | Navigate items | Dropdowns, lists |
|
||||
| `Home` | Jump to start | Lists |
|
||||
| `End` | Jump to end | Lists |
|
||||
| Key | Action | Context |
|
||||
| ------------- | ------------------------ | ------------------- |
|
||||
| `Tab` | Move focus forward | All |
|
||||
| `Shift + Tab` | Move focus backward | All |
|
||||
| `Enter` | Activate button/link | Buttons, links |
|
||||
| `Space` | Activate button/checkbox | Buttons, checkboxes |
|
||||
| `Escape` | Close overlay | Dialogs, dropdowns |
|
||||
| `Arrow keys` | Navigate items | Dropdowns, lists |
|
||||
| `Home` | Jump to start | Lists |
|
||||
| `End` | Jump to end | Lists |
|
||||
|
||||
---
|
||||
|
||||
@@ -482,7 +476,13 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
|
||||
// Utilities
|
||||
@@ -506,53 +506,53 @@ import { Check, X, AlertCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
```tsx
|
||||
// Required string
|
||||
z.string().min(1, 'Required')
|
||||
z.string().min(1, 'Required');
|
||||
|
||||
// Email
|
||||
z.string().email('Invalid email')
|
||||
z.string().email('Invalid email');
|
||||
|
||||
// Min/max length
|
||||
z.string().min(8, 'Min 8 chars').max(100, 'Max 100 chars')
|
||||
z.string().min(8, 'Min 8 chars').max(100, 'Max 100 chars');
|
||||
|
||||
// Optional
|
||||
z.string().optional()
|
||||
z.string().optional();
|
||||
|
||||
// Number
|
||||
z.coerce.number().min(0).max(100)
|
||||
z.coerce.number().min(0).max(100);
|
||||
|
||||
// Enum
|
||||
z.enum(['admin', 'user', 'guest'])
|
||||
z.enum(['admin', 'user', 'guest']);
|
||||
|
||||
// Boolean
|
||||
z.boolean().refine(val => val === true, { message: 'Must accept' })
|
||||
z.boolean().refine((val) => val === true, { message: 'Must accept' });
|
||||
|
||||
// Password confirmation
|
||||
z.object({
|
||||
password: z.string().min(8),
|
||||
confirmPassword: z.string()
|
||||
}).refine(data => data.password === data.confirmPassword, {
|
||||
confirmPassword: z.string(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
| Breakpoint | Min Width | Typical Device |
|
||||
|------------|-----------|----------------|
|
||||
| `sm:` | 640px | Large phones, small tablets |
|
||||
| `md:` | 768px | Tablets |
|
||||
| `lg:` | 1024px | Laptops, desktops |
|
||||
| `xl:` | 1280px | Large desktops |
|
||||
| `2xl:` | 1536px | Extra large screens |
|
||||
| Breakpoint | Min Width | Typical Device |
|
||||
| ---------- | --------- | --------------------------- |
|
||||
| `sm:` | 640px | Large phones, small tablets |
|
||||
| `md:` | 768px | Tablets |
|
||||
| `lg:` | 1024px | Laptops, desktops |
|
||||
| `xl:` | 1280px | Large desktops |
|
||||
| `2xl:` | 1536px | Extra large screens |
|
||||
|
||||
```tsx
|
||||
// Mobile-first (default → sm → md → lg)
|
||||
className="text-sm sm:text-base md:text-lg lg:text-xl"
|
||||
className="p-4 sm:p-6 lg:p-8"
|
||||
className="grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
|
||||
className = 'text-sm sm:text-base md:text-lg lg:text-xl';
|
||||
className = 'p-4 sm:p-6 lg:p-8';
|
||||
className = 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3';
|
||||
```
|
||||
|
||||
---
|
||||
@@ -562,20 +562,20 @@ className="grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
|
||||
### Shadows
|
||||
|
||||
```tsx
|
||||
shadow-sm // Cards, panels
|
||||
shadow-md // Dropdowns, tooltips
|
||||
shadow-lg // Modals, popovers
|
||||
shadow-xl // Floating notifications
|
||||
shadow - sm; // Cards, panels
|
||||
shadow - md; // Dropdowns, tooltips
|
||||
shadow - lg; // Modals, popovers
|
||||
shadow - xl; // Floating notifications
|
||||
```
|
||||
|
||||
### Border Radius
|
||||
|
||||
```tsx
|
||||
rounded-sm // 2px - Tags, small badges
|
||||
rounded-md // 4px - Inputs, small buttons
|
||||
rounded-lg // 6px - Cards, buttons (default)
|
||||
rounded-xl // 10px - Large cards, modals
|
||||
rounded-full // Pills, avatars, icon buttons
|
||||
rounded - sm; // 2px - Tags, small badges
|
||||
rounded - md; // 4px - Inputs, small buttons
|
||||
rounded - lg; // 6px - Cards, buttons (default)
|
||||
rounded - xl; // 10px - Large cards, modals
|
||||
rounded - full; // Pills, avatars, icon buttons
|
||||
```
|
||||
|
||||
---
|
||||
@@ -589,6 +589,7 @@ rounded-full // Pills, avatars, icon buttons
|
||||
---
|
||||
|
||||
**Related Documentation:**
|
||||
|
||||
- [Quick Start](./00-quick-start.md) - 5-minute crash course
|
||||
- [Foundations](./01-foundations.md) - Detailed color, typography, spacing
|
||||
- [Components](./02-components.md) - All component variants
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
## Project Overview
|
||||
|
||||
### Vision
|
||||
|
||||
Create a world-class design system documentation that:
|
||||
|
||||
- Follows Pareto principle (80% coverage with 20% content)
|
||||
- Includes AI-specific code generation guidelines
|
||||
- Provides interactive, copy-paste examples
|
||||
@@ -18,6 +20,7 @@ Create a world-class design system documentation that:
|
||||
- Maintains perfect internal coherence and link integrity
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Pareto-Efficiency** - 80/20 rule applied throughout
|
||||
2. **AI-Optimized** - Dedicated guidelines for AI code generation
|
||||
3. **Interconnected** - All docs cross-reference each other
|
||||
@@ -163,6 +166,7 @@ Create a world-class design system documentation that:
|
||||
### Documentation Review & Fixes ✅
|
||||
|
||||
#### Issues Found During Review:
|
||||
|
||||
1. **Time estimates in section headers** - Removed all (user request)
|
||||
- Removed "⏱️ Time to productive: 5 minutes" from header
|
||||
- Removed "(3 minutes)", "(30 seconds)" from all section headers
|
||||
@@ -178,6 +182,7 @@ Create a world-class design system documentation that:
|
||||
- Fixed: Added missing `SelectGroup` and `SelectLabel` to import statement in 02-components.md
|
||||
|
||||
#### Comprehensive Review Results:
|
||||
|
||||
- **✅ 100+ links checked**
|
||||
- **✅ 0 broken internal doc links**
|
||||
- **✅ 0 logic inconsistencies**
|
||||
@@ -200,7 +205,9 @@ Create a world-class design system documentation that:
|
||||
## Phase 2: Interactive Demos (PENDING)
|
||||
|
||||
### Objective
|
||||
|
||||
Create live, interactive demonstration pages at `/dev/*` routes with:
|
||||
|
||||
- Copy-paste ready code snippets
|
||||
- Before/after comparisons
|
||||
- Live component examples
|
||||
@@ -272,12 +279,14 @@ Create live, interactive demonstration pages at `/dev/*` routes with:
|
||||
### Overall Progress: 100% Complete ✅
|
||||
|
||||
**Phase 1: Documentation** ✅ 100% (14/14 tasks)
|
||||
|
||||
- All documentation files created (~7,600 lines)
|
||||
- All issues fixed (4 issues resolved)
|
||||
- Comprehensive review completed (100+ links verified)
|
||||
- CLAUDE.md updated
|
||||
|
||||
**Phase 2: Interactive Demos** ✅ 100% (6/6 tasks)
|
||||
|
||||
- Utility components created (~470 lines)
|
||||
- Hub page created (~220 lines)
|
||||
- All demo pages created and enhanced (~2,388 lines)
|
||||
@@ -386,6 +395,7 @@ Create live, interactive demonstration pages at `/dev/*` routes with:
|
||||
### Technical Implementation
|
||||
|
||||
**Technologies Used:**
|
||||
|
||||
- Next.js 15 App Router
|
||||
- React 19 + TypeScript
|
||||
- shadcn/ui components (all)
|
||||
@@ -395,6 +405,7 @@ Create live, interactive demonstration pages at `/dev/*` routes with:
|
||||
- Responsive design (mobile-first)
|
||||
|
||||
**Architecture:**
|
||||
|
||||
- Server components for static pages (hub, layouts, spacing)
|
||||
- Client components for interactive pages (components, forms)
|
||||
- Reusable utility components in `/src/components/dev/`
|
||||
|
||||
@@ -6,26 +6,28 @@
|
||||
|
||||
## 🚀 Quick Navigation
|
||||
|
||||
| For... | Start Here | Time |
|
||||
|--------|-----------|------|
|
||||
| **Quick Start** | [⚡ 5-Minute Crash Course](./00-quick-start.md) | 5 min |
|
||||
| **Component Development** | [🧩 Components](./02-components.md) → [🔨 Creation Guide](./05-component-creation.md) | 15 min |
|
||||
| **Layout Design** | [📐 Layouts](./03-layouts.md) → [📏 Spacing](./04-spacing-philosophy.md) | 20 min |
|
||||
| **AI Code Generation** | [🤖 AI Guidelines](./08-ai-guidelines.md) | 3 min |
|
||||
| **Quick Reference** | [📚 Reference Tables](./99-reference.md) | Instant |
|
||||
| **Complete Guide** | Read all docs in order | 1 hour |
|
||||
| For... | Start Here | Time |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------- | ------- |
|
||||
| **Quick Start** | [⚡ 5-Minute Crash Course](./00-quick-start.md) | 5 min |
|
||||
| **Component Development** | [🧩 Components](./02-components.md) → [🔨 Creation Guide](./05-component-creation.md) | 15 min |
|
||||
| **Layout Design** | [📐 Layouts](./03-layouts.md) → [📏 Spacing](./04-spacing-philosophy.md) | 20 min |
|
||||
| **AI Code Generation** | [🤖 AI Guidelines](./08-ai-guidelines.md) | 3 min |
|
||||
| **Quick Reference** | [📚 Reference Tables](./99-reference.md) | Instant |
|
||||
| **Complete Guide** | Read all docs in order | 1 hour |
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation Structure
|
||||
|
||||
### Getting Started
|
||||
|
||||
- **[00. Quick Start](./00-quick-start.md)** ⚡
|
||||
- 5-minute crash course
|
||||
- Essential components and patterns
|
||||
- Copy-paste ready examples
|
||||
|
||||
### Fundamentals
|
||||
|
||||
- **[01. Foundations](./01-foundations.md)** 🎨
|
||||
- Color system (OKLCH)
|
||||
- Typography scale
|
||||
@@ -39,6 +41,7 @@
|
||||
- Composition patterns
|
||||
|
||||
### Layouts & Spacing
|
||||
|
||||
- **[03. Layouts](./03-layouts.md)** 📐
|
||||
- Grid vs Flex decision tree
|
||||
- Common layout patterns
|
||||
@@ -52,6 +55,7 @@
|
||||
- Consistency patterns
|
||||
|
||||
### Building Components
|
||||
|
||||
- **[05. Component Creation](./05-component-creation.md)** 🔨
|
||||
- When to create vs compose
|
||||
- Component templates
|
||||
@@ -65,6 +69,7 @@
|
||||
- Multi-field examples
|
||||
|
||||
### Best Practices
|
||||
|
||||
- **[07. Accessibility](./07-accessibility.md)** ♿
|
||||
- WCAG AA compliance
|
||||
- Keyboard navigation
|
||||
@@ -78,6 +83,7 @@
|
||||
- Component templates
|
||||
|
||||
### Reference
|
||||
|
||||
- **[99. Reference Tables](./99-reference.md)** 📚
|
||||
- Quick lookup tables
|
||||
- All tokens at a glance
|
||||
@@ -95,6 +101,7 @@ Explore live examples and copy-paste code:
|
||||
- **[Form Patterns](/dev/forms)** - Complete form examples
|
||||
|
||||
Each demo page includes:
|
||||
|
||||
- ✅ Live, interactive examples
|
||||
- ✅ Click-to-copy code snippets
|
||||
- ✅ Before/after comparisons
|
||||
@@ -105,6 +112,7 @@ Each demo page includes:
|
||||
## 🛤️ Learning Paths
|
||||
|
||||
### Path 1: Speedrun (5 minutes)
|
||||
|
||||
**Goal**: Start building immediately
|
||||
|
||||
1. [Quick Start](./00-quick-start.md) - Essential patterns
|
||||
@@ -116,6 +124,7 @@ Each demo page includes:
|
||||
---
|
||||
|
||||
### Path 2: Component Developer (15 minutes)
|
||||
|
||||
**Goal**: Master component building
|
||||
|
||||
1. [Quick Start](./00-quick-start.md) - Basics
|
||||
@@ -128,6 +137,7 @@ Each demo page includes:
|
||||
---
|
||||
|
||||
### Path 3: Layout Specialist (20 minutes)
|
||||
|
||||
**Goal**: Master layouts and spacing
|
||||
|
||||
1. [Quick Start](./00-quick-start.md) - Basics
|
||||
@@ -141,6 +151,7 @@ Each demo page includes:
|
||||
---
|
||||
|
||||
### Path 4: Form Specialist (15 minutes)
|
||||
|
||||
**Goal**: Master forms and validation
|
||||
|
||||
1. [Quick Start](./00-quick-start.md) - Basics
|
||||
@@ -154,6 +165,7 @@ Each demo page includes:
|
||||
---
|
||||
|
||||
### Path 5: AI Setup (3 minutes)
|
||||
|
||||
**Goal**: Configure AI for perfect code generation
|
||||
|
||||
1. [AI Guidelines](./08-ai-guidelines.md) - Read once, code forever
|
||||
@@ -164,9 +176,11 @@ Each demo page includes:
|
||||
---
|
||||
|
||||
### Path 6: Comprehensive Mastery (1 hour)
|
||||
|
||||
**Goal**: Complete understanding of the design system
|
||||
|
||||
Read all documents in order:
|
||||
|
||||
1. [Quick Start](./00-quick-start.md)
|
||||
2. [Foundations](./01-foundations.md)
|
||||
3. [Components](./02-components.md)
|
||||
@@ -211,18 +225,21 @@ Our design system is built on these core principles:
|
||||
## 🤝 Contributing to the Design System
|
||||
|
||||
### Adding a New Component
|
||||
|
||||
1. Read [Component Creation Guide](./05-component-creation.md)
|
||||
2. Follow the template
|
||||
3. Add to [Component Showcase](/dev/components)
|
||||
4. Document in [Components](./02-components.md)
|
||||
|
||||
### Adding a New Pattern
|
||||
|
||||
1. Validate it solves a real need (used 3+ times)
|
||||
2. Document in appropriate guide
|
||||
3. Add to [Reference](./99-reference.md)
|
||||
4. Create example in `/dev/`
|
||||
|
||||
### Updating Colors/Tokens
|
||||
|
||||
1. Edit `src/app/globals.css`
|
||||
2. Test in both light and dark modes
|
||||
3. Verify WCAG AA contrast
|
||||
|
||||
@@ -63,6 +63,7 @@ npm run test:e2e -- --debug
|
||||
**Test Results:** 34/43 passing (79% pass rate)
|
||||
|
||||
### Passing Tests ✅
|
||||
|
||||
- All AuthGuard tests (8/8)
|
||||
- Most Login tests (6/8)
|
||||
- Most Registration tests (7/11)
|
||||
@@ -83,6 +84,7 @@ The 9 failing tests are due to minor validation message text mismatches between
|
||||
### Recommendations
|
||||
|
||||
These failures can be fixed by:
|
||||
|
||||
1. Inspecting the actual error messages rendered by forms
|
||||
2. Updating test assertions to match exact wording
|
||||
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
|
||||
|
||||
See `playwright.config.ts` for:
|
||||
|
||||
- Browser targets (Chromium, Firefox, WebKit)
|
||||
- Base URL configuration
|
||||
- Screenshot and video settings
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
setupAuthenticatedMocks,
|
||||
setupSuperuserMocks,
|
||||
} from './helpers/auth';
|
||||
import { setupAuthenticatedMocks, setupSuperuserMocks } from './helpers/auth';
|
||||
|
||||
test.describe('Admin Access Control', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
test('regular user should be redirected when accessing admin page directly', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('regular user should be redirected when accessing admin page directly', async ({ page }) => {
|
||||
// Set up mocks for regular user
|
||||
await setupAuthenticatedMocks(page);
|
||||
// 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');
|
||||
});
|
||||
|
||||
test('superuser should be able to access admin dashboard', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('superuser should be able to access admin dashboard', async ({ page }) => {
|
||||
// Set up mocks for superuser
|
||||
await setupSuperuserMocks(page);
|
||||
// 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: 'Active Users' })).toBeVisible();
|
||||
await expect(statTitles.filter({ hasText: 'Organizations' })).toBeVisible();
|
||||
await expect(
|
||||
statTitles.filter({ hasText: 'Active Sessions' })
|
||||
).toBeVisible();
|
||||
await expect(statTitles.filter({ hasText: 'Active Sessions' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display quick action cards', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Quick Actions', exact: true })
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Quick Actions', exact: true })).toBeVisible();
|
||||
|
||||
// Should have three action cards (use unique descriptive text to avoid sidebar matches)
|
||||
await expect(
|
||||
page.getByText('View, create, and manage user accounts')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Manage organizations and their members')
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('View, create, and manage user accounts')).toBeVisible();
|
||||
await expect(page.getByText('Manage organizations and their members')).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();
|
||||
});
|
||||
|
||||
test('should navigate back to dashboard from users page', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('should navigate back to dashboard from users page', async ({ page }) => {
|
||||
await page.goto('/admin/users');
|
||||
|
||||
// Click dashboard link in sidebar
|
||||
@@ -275,10 +258,7 @@ test.describe('Admin Breadcrumbs', () => {
|
||||
// Click 'Admin' breadcrumb to go back to dashboard
|
||||
const adminBreadcrumb = page.getByTestId('breadcrumb-admin');
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL('/admin'),
|
||||
adminBreadcrumb.click()
|
||||
]);
|
||||
await Promise.all([page.waitForURL('/admin'), adminBreadcrumb.click()]);
|
||||
|
||||
await expect(page).toHaveURL('/admin');
|
||||
});
|
||||
|
||||
@@ -74,18 +74,19 @@ test.describe('Admin Dashboard - Quick Actions', () => {
|
||||
const quickActionsSection = page.locator('h2:has-text("Quick Actions")').locator('..');
|
||||
|
||||
// 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: 'System Settings' })).toBeVisible();
|
||||
await expect(
|
||||
quickActionsSection.getByRole('heading', { name: 'System Settings' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate to users page when clicking user management', async ({ page }) => {
|
||||
const userManagementLink = page.getByRole('link', { name: /User Management/i });
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL('/admin/users'),
|
||||
userManagementLink.click()
|
||||
]);
|
||||
await Promise.all([page.waitForURL('/admin/users'), userManagementLink.click()]);
|
||||
|
||||
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 organizationsLink = quickActionsSection.getByRole('link', { name: /Organizations/i });
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL('/admin/organizations'),
|
||||
organizationsLink.click()
|
||||
]);
|
||||
await Promise.all([page.waitForURL('/admin/organizations'), organizationsLink.click()]);
|
||||
|
||||
await expect(page).toHaveURL('/admin/organizations');
|
||||
});
|
||||
|
||||
@@ -15,7 +15,9 @@ test.describe('Admin Organization Members - Navigation from Organizations List',
|
||||
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
|
||||
const actionButton = page.getByRole('button', { name: /Actions for/i }).first();
|
||||
await actionButton.click();
|
||||
@@ -23,7 +25,7 @@ test.describe('Admin Organization Members - Navigation from Organizations List',
|
||||
// Click "View Members"
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
||||
page.getByText('View Members').click()
|
||||
page.getByText('View Members').click(),
|
||||
]);
|
||||
|
||||
// Should be on members page
|
||||
@@ -38,7 +40,7 @@ test.describe('Admin Organization Members - Navigation from Organizations List',
|
||||
// Click on member count
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
||||
memberButton.click()
|
||||
memberButton.click(),
|
||||
]);
|
||||
|
||||
// Should be on members page
|
||||
@@ -59,7 +61,7 @@ test.describe('Admin Organization Members - Page Structure', () => {
|
||||
|
||||
await Promise.all([
|
||||
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 }) => {
|
||||
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 }) => {
|
||||
@@ -87,7 +91,6 @@ test.describe('Admin Organization Members - Page Structure', () => {
|
||||
await expect(backButton).toBeVisible();
|
||||
});
|
||||
|
||||
|
||||
test('should have proper heading hierarchy', async ({ page }) => {
|
||||
// Wait for page to load
|
||||
await page.waitForSelector('table');
|
||||
@@ -129,7 +132,7 @@ test.describe('Admin Organization Members - AddMemberDialog E2E Tests', () => {
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
||||
page.getByText('View Members').click()
|
||||
page.getByText('View Members').click(),
|
||||
]);
|
||||
|
||||
// Open Add Member dialog
|
||||
@@ -150,7 +153,9 @@ test.describe('Admin Organization Members - AddMemberDialog E2E Tests', () => {
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
|
||||
@@ -193,7 +193,7 @@ test.describe('Admin Organization Management - Action Menu', () => {
|
||||
// Click view members - use Promise.all for Next.js Link navigation
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
||||
page.getByText('View Members').click()
|
||||
page.getByText('View Members').click(),
|
||||
]);
|
||||
|
||||
// Should navigate to members page
|
||||
@@ -220,7 +220,9 @@ test.describe('Admin Organization Management - Action Menu', () => {
|
||||
await page.getByText('Delete Organization').click();
|
||||
|
||||
// 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 }) => {
|
||||
@@ -307,7 +309,7 @@ test.describe('Admin Organization Management - Member Count Interaction', () =>
|
||||
// Click on member count - use Promise.all for Next.js Link navigation
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/admin\/organizations\/[^/]+\/members/),
|
||||
memberButton.click()
|
||||
memberButton.click(),
|
||||
]);
|
||||
|
||||
// Should navigate to members page
|
||||
|
||||
@@ -132,10 +132,13 @@ test.describe('Admin User Management - Search and Filters', () => {
|
||||
await searchInput.fill('admin');
|
||||
|
||||
// Wait for debounce and URL to update
|
||||
await page.waitForFunction(() => {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.has('search');
|
||||
}, { timeout: 2000 });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.has('search');
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
|
||||
// Check that URL contains search parameter
|
||||
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
|
||||
// (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();
|
||||
await statusFilter.click();
|
||||
|
||||
// Click on "Inactive" option and wait for URL update
|
||||
await Promise.all([
|
||||
page.waitForFunction(() => {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get('active') === 'false';
|
||||
}, { timeout: 2000 }),
|
||||
page.getByRole('option', { name: 'Inactive' }).click()
|
||||
page.waitForFunction(
|
||||
() => {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get('active') === 'false';
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
),
|
||||
page.getByRole('option', { name: 'Inactive' }).click(),
|
||||
]);
|
||||
|
||||
// Check that URL contains active=false parameter
|
||||
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);
|
||||
await userTypeFilter.click();
|
||||
|
||||
// Click on "Superusers" option and wait for URL update
|
||||
await Promise.all([
|
||||
page.waitForFunction(() => {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get('superuser') === 'true';
|
||||
}, { timeout: 2000 }),
|
||||
page.getByRole('option', { name: 'Superusers' }).click()
|
||||
page.waitForFunction(
|
||||
() => {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get('superuser') === 'true';
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
),
|
||||
page.getByRole('option', { name: 'Superusers' }).click(),
|
||||
]);
|
||||
|
||||
// Check that URL contains superuser parameter
|
||||
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);
|
||||
await userTypeFilter.click();
|
||||
|
||||
// Click on "Regular" option and wait for URL update
|
||||
await Promise.all([
|
||||
page.waitForFunction(() => {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get('superuser') === 'false';
|
||||
}, { timeout: 2000 }),
|
||||
page.getByRole('option', { name: 'Regular' }).click()
|
||||
page.waitForFunction(
|
||||
() => {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get('superuser') === 'false';
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
),
|
||||
page.getByRole('option', { name: 'Regular' }).click(),
|
||||
]);
|
||||
|
||||
// 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);
|
||||
await searchInput.fill('test');
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.has('search');
|
||||
}, { timeout: 2000 });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.has('search');
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
|
||||
// URL should have page=1 or no page param (defaults to 1)
|
||||
const newUrl = page.url();
|
||||
@@ -502,9 +523,7 @@ test.describe('Admin User Management - Edit User Dialog', () => {
|
||||
await page.getByText('Edit User').click();
|
||||
|
||||
// Password field should indicate it's optional
|
||||
await expect(
|
||||
page.getByLabel(/Password.*\(leave blank to keep current\)/i)
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel(/Password.*\(leave blank to keep current\)/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should have placeholder for password in edit mode', async ({ page }) => {
|
||||
|
||||
@@ -11,9 +11,7 @@ test.describe('AuthGuard - Route Protection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should redirect to login when accessing protected route without auth', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('should redirect to login when accessing protected route without auth', async ({ page }) => {
|
||||
// Try to access a protected route (if you have one)
|
||||
// For now, we'll test the root if it's protected
|
||||
// Adjust the route based on your actual protected routes
|
||||
|
||||
@@ -162,10 +162,7 @@ test.describe('Login Flow', () => {
|
||||
// Click forgot password link - use Promise.all to wait for navigation
|
||||
const forgotLink = page.getByRole('link', { name: 'Forgot password?' });
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL('/password-reset'),
|
||||
forgotLink.click()
|
||||
]);
|
||||
await Promise.all([page.waitForURL('/password-reset'), forgotLink.click()]);
|
||||
|
||||
// Should be on password reset page
|
||||
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
|
||||
const signupLink = page.getByRole('link', { name: 'Sign up' });
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL('/register'),
|
||||
signupLink.click()
|
||||
]);
|
||||
await Promise.all([page.waitForURL('/register'), signupLink.click()]);
|
||||
|
||||
// Should be on register page
|
||||
await expect(page).toHaveURL('/register');
|
||||
|
||||
@@ -55,10 +55,7 @@ test.describe('Password Reset Request Flow', () => {
|
||||
// Click back to login link - use Promise.all to wait for navigation
|
||||
const loginLink = page.getByRole('link', { name: 'Back to login' });
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL('/login', ),
|
||||
loginLink.click()
|
||||
]);
|
||||
await Promise.all([page.waitForURL('/login'), loginLink.click()]);
|
||||
|
||||
// Should be on login page
|
||||
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
|
||||
const resetLink = page.getByRole('link', { name: 'Request new reset link' });
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL('/password-reset', ),
|
||||
resetLink.click()
|
||||
]);
|
||||
await Promise.all([page.waitForURL('/password-reset'), resetLink.click()]);
|
||||
|
||||
// Should be on password reset request page
|
||||
await expect(page).toHaveURL('/password-reset');
|
||||
|
||||
@@ -222,10 +222,7 @@ test.describe('Registration Flow', () => {
|
||||
const loginLink = page.getByRole('link', { name: 'Sign in' });
|
||||
|
||||
// Use Promise.all to wait for navigation
|
||||
await Promise.all([
|
||||
page.waitForURL('/login'),
|
||||
loginLink.click()
|
||||
]);
|
||||
await Promise.all([page.waitForURL('/login'), loginLink.click()]);
|
||||
|
||||
// Should be on login page
|
||||
await expect(page).toHaveURL('/login');
|
||||
|
||||
@@ -95,7 +95,11 @@ export const MOCK_ORGANIZATIONS = [
|
||||
* @param email User email (defaults to mock user email)
|
||||
* @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
|
||||
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);
|
||||
|
||||
// Submit and wait for navigation to home
|
||||
await Promise.all([
|
||||
page.waitForURL('/'),
|
||||
page.locator('button[type="submit"]').click(),
|
||||
]);
|
||||
await Promise.all([page.waitForURL('/'), page.locator('button[type="submit"]').click()]);
|
||||
|
||||
// Wait for auth to settle
|
||||
await page.waitForTimeout(500);
|
||||
@@ -136,8 +137,10 @@ export async function setupAuthenticatedMocks(page: Page): Promise<void> {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
user: MOCK_USER,
|
||||
access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDEiLCJleHAiOjk5OTk5OTk5OTl9.signature',
|
||||
refresh_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDIiLCJleHAiOjk5OTk5OTk5OTl9.signature',
|
||||
access_token:
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDEiLCJleHAiOjk5OTk5OTk5OTl9.signature',
|
||||
refresh_token:
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDIiLCJleHAiOjk5OTk5OTk5OTl9.signature',
|
||||
expires_in: 3600,
|
||||
token_type: 'bearer',
|
||||
}),
|
||||
@@ -239,8 +242,10 @@ export async function setupSuperuserMocks(page: Page): Promise<void> {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
user: MOCK_SUPERUSER,
|
||||
access_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDMiLCJleHAiOjk5OTk5OTk5OTl9.signature',
|
||||
refresh_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDQiLCJleHAiOjk5OTk5OTk5OTl9.signature',
|
||||
access_token:
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDMiLCJleHAiOjk5OTk5OTk5OTl9.signature',
|
||||
refresh_token:
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDQiLCJleHAiOjk5OTk5OTk5OTl9.signature',
|
||||
expires_in: 3600,
|
||||
token_type: 'bearer',
|
||||
}),
|
||||
@@ -376,7 +381,7 @@ export async function setupSuperuserMocks(page: Page): Promise<void> {
|
||||
if (route.request().method() === 'GET' && isSingleOrgEndpoint) {
|
||||
// Extract org ID from URL
|
||||
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({
|
||||
status: 200,
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function startCoverage(
|
||||
}
|
||||
|
||||
try {
|
||||
await page.coverage.startJSCoverage({
|
||||
await page.coverage.startJSCoverage({
|
||||
resetOnNavigation: options?.resetOnNavigation ?? false,
|
||||
// @ts-expect-error - includeRawScriptCoverage is not in official types but supported by Playwright
|
||||
includeRawScriptCoverage: options?.includeRawScriptCoverage ?? false,
|
||||
@@ -201,9 +201,9 @@ export const withCoverage = {
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
.replace(/[^a-z0-9\s-]/gi, '') // Remove special chars
|
||||
.replace(/\s+/g, '_') // Replace spaces with underscores
|
||||
.replace(/\s+/g, '_') // Replace spaces with underscores
|
||||
.toLowerCase()
|
||||
.substring(0, 100); // Limit length
|
||||
.substring(0, 100); // Limit length
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,16 +70,16 @@ test.describe('Homepage - Desktop Navigation', () => {
|
||||
const header = page.locator('header').first();
|
||||
const headerLoginLink = header.getByRole('link', { name: /^Login$/i });
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL('/login'),
|
||||
headerLoginLink.click()
|
||||
]);
|
||||
await Promise.all([page.waitForURL('/login'), headerLoginLink.click()]);
|
||||
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
|
||||
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)
|
||||
const dialog = page.getByRole('dialog');
|
||||
@@ -204,10 +204,7 @@ test.describe('Homepage - Mobile Menu Interactions', () => {
|
||||
const loginLink = mobileMenu.getByRole('link', { name: /Login/i });
|
||||
await loginLink.waitFor({ state: 'visible' });
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL('/login'),
|
||||
loginLink.click()
|
||||
]);
|
||||
await Promise.all([page.waitForURL('/login'), loginLink.click()]);
|
||||
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
@@ -230,7 +227,9 @@ test.describe('Homepage - Hero Section', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -274,7 +273,10 @@ test.describe('Homepage - Demo Credentials Modal', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
await dialog.waitFor({ state: 'visible' });
|
||||
@@ -292,7 +294,10 @@ test.describe('Homepage - Demo Credentials Modal', () => {
|
||||
// Grant clipboard permissions
|
||||
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');
|
||||
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 }) => {
|
||||
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');
|
||||
await dialog.waitFor({ state: 'visible' });
|
||||
|
||||
const loginLink = dialog.getByRole('link', { name: /Go to Login/i });
|
||||
|
||||
await Promise.all([
|
||||
page.waitForURL('/login'),
|
||||
loginLink.click()
|
||||
]);
|
||||
await Promise.all([page.waitForURL('/login'), loginLink.click()]);
|
||||
|
||||
await expect(page).toHaveURL('/login');
|
||||
});
|
||||
|
||||
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');
|
||||
await dialog.waitFor({ state: 'visible' });
|
||||
@@ -343,7 +351,9 @@ test.describe('Homepage - Animated Terminal', () => {
|
||||
// Scroll to terminal section
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -434,7 +444,9 @@ test.describe('Homepage - Feature Sections', () => {
|
||||
});
|
||||
|
||||
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
|
||||
await expect(page.getByText('FastAPI').first()).toBeVisible();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
const nextJest = require('next/jest')
|
||||
const nextJest = require('next/jest');
|
||||
|
||||
const createJestConfig = nextJest({
|
||||
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
|
||||
dir: './',
|
||||
})
|
||||
});
|
||||
|
||||
// Add any custom config to be passed to Jest
|
||||
const customJestConfig = {
|
||||
@@ -12,10 +12,7 @@ const customJestConfig = {
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
testMatch: [
|
||||
'<rootDir>/tests/**/*.test.ts',
|
||||
'<rootDir>/tests/**/*.test.tsx',
|
||||
],
|
||||
testMatch: ['<rootDir>/tests/**/*.test.ts', '<rootDir>/tests/**/*.test.tsx'],
|
||||
transformIgnorePatterns: [
|
||||
'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,
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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 { Crypto } from '@peculiar/webcrypto';
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,24 @@
|
||||
import type { NextConfig } from "next";
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
// Ensure we can connect to the backend in Docker
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://backend:8000/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
// ESLint configuration
|
||||
eslint: {
|
||||
ignoreDuringBuilds: false,
|
||||
dirs: ['src'],
|
||||
},
|
||||
// Production optimizations
|
||||
reactStrictMode: true,
|
||||
// Note: swcMinify is default in Next.js 15
|
||||
output: 'standalone',
|
||||
// Ensure we can connect to the backend in Docker
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://backend:8000/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
// ESLint configuration
|
||||
eslint: {
|
||||
ignoreDuringBuilds: false,
|
||||
dirs: ['src'],
|
||||
},
|
||||
// Production optimizations
|
||||
reactStrictMode: true,
|
||||
// Note: swcMinify is default in Next.js 15
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
plugins: ['@tailwindcss/postcss'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -94,7 +94,7 @@ async function convertV8ToIstanbul() {
|
||||
|
||||
// Read all V8 coverage files
|
||||
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) {
|
||||
console.log('⚠️ No coverage files found in:', rawDir);
|
||||
@@ -122,10 +122,7 @@ async function convertV8ToIstanbul() {
|
||||
for (const entry of v8Coverage) {
|
||||
try {
|
||||
// Skip non-source files
|
||||
if (
|
||||
!entry.url.startsWith('http://localhost') &&
|
||||
!entry.url.startsWith('file://')
|
||||
) {
|
||||
if (!entry.url.startsWith('http://localhost') && !entry.url.startsWith('file://')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -174,7 +171,6 @@ async function convertV8ToIstanbul() {
|
||||
// Merge into combined coverage
|
||||
Object.assign(istanbulCoverage, converted);
|
||||
totalConverted++;
|
||||
|
||||
} catch (error: any) {
|
||||
console.log(` ⚠️ Skipped ${entry.url}: ${error.message}`);
|
||||
totalSkipped++;
|
||||
@@ -198,7 +194,7 @@ async function convertV8ToIstanbul() {
|
||||
|
||||
if (totalConverted === 0) {
|
||||
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(' • Source maps are not correctly configured\n');
|
||||
console.log('💡 Consider using Istanbul instrumentation instead (see guide)\n');
|
||||
|
||||
@@ -59,11 +59,9 @@ async function mergeCoverage() {
|
||||
const jestCoveragePath = path.join(process.cwd(), 'coverage/coverage-final.json');
|
||||
|
||||
if (fs.existsSync(jestCoveragePath)) {
|
||||
const jestCoverage: CoverageData = JSON.parse(
|
||||
fs.readFileSync(jestCoveragePath, 'utf-8')
|
||||
);
|
||||
const jestCoverage: CoverageData = JSON.parse(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;
|
||||
|
||||
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');
|
||||
|
||||
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) {
|
||||
console.log(' ⚠️ No E2E coverage files found in:', e2eDir);
|
||||
@@ -89,7 +87,7 @@ async function mergeCoverage() {
|
||||
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);
|
||||
console.log(` ✅ Loaded E2E coverage from: ${file}`);
|
||||
}
|
||||
@@ -104,7 +102,7 @@ async function mergeCoverage() {
|
||||
// Step 3: Calculate statistics
|
||||
stats.combinedFiles = map.files().length;
|
||||
|
||||
map.files().forEach(file => {
|
||||
map.files().forEach((file) => {
|
||||
const inJest = jestFiles.has(file);
|
||||
const inE2E = e2eFiles.has(file);
|
||||
|
||||
@@ -146,10 +144,18 @@ async function mergeCoverage() {
|
||||
console.log('\n' + '='.repeat(70));
|
||||
console.log('📊 COMBINED COVERAGE SUMMARY');
|
||||
console.log('='.repeat(70));
|
||||
console.log(`\n Statements: ${summary.statements.pct.toFixed(2)}% (${summary.statements.covered}/${summary.statements.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 Statements: ${summary.statements.pct.toFixed(2)}% (${summary.statements.covered}/${summary.statements.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('📁 FILE COVERAGE BREAKDOWN');
|
||||
@@ -162,10 +168,12 @@ async function mergeCoverage() {
|
||||
// Show E2E-only files (these were excluded from Jest)
|
||||
if (stats.e2eOnlyFiles.length > 0) {
|
||||
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 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) {
|
||||
console.log(` ... and ${stats.e2eOnlyFiles.length - 10} more`);
|
||||
@@ -190,7 +198,9 @@ async function mergeCoverage() {
|
||||
const actual = (summary as any)[metric].pct;
|
||||
const passed = actual >= threshold;
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,6 @@ export const metadata: Metadata = {
|
||||
title: 'Authentication',
|
||||
};
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AuthLayoutClient>{children}</AuthLayoutClient>;
|
||||
}
|
||||
|
||||
@@ -19,18 +19,13 @@ export default function LoginPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Sign in to your account</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Access your dashboard and manage your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LoginForm
|
||||
showRegisterLink
|
||||
showPasswordResetLink
|
||||
/>
|
||||
<LoginForm showRegisterLink showPasswordResetLink />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ import Link from 'next/link';
|
||||
// Code-split PasswordResetConfirmForm (319 lines)
|
||||
const PasswordResetConfirmForm = dynamic(
|
||||
/* 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: () => (
|
||||
<div className="space-y-4">
|
||||
@@ -53,15 +56,12 @@ export default function PasswordResetConfirmContent() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Invalid Reset Link
|
||||
</h2>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Invalid Reset Link</h2>
|
||||
</div>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<p className="text-sm">
|
||||
This password reset link is invalid or has expired. Please request a new
|
||||
password reset.
|
||||
This password reset link is invalid or has expired. Please request a new password reset.
|
||||
</p>
|
||||
</Alert>
|
||||
|
||||
|
||||
@@ -8,16 +8,16 @@ import PasswordResetConfirmContent from './PasswordResetConfirmContent';
|
||||
|
||||
export default function PasswordResetConfirmPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Set new password</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</p>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Set new password</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
}
|
||||
>
|
||||
<PasswordResetConfirmContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -8,9 +8,10 @@ import dynamic from 'next/dynamic';
|
||||
// Code-split PasswordResetRequestForm
|
||||
const PasswordResetRequestForm = dynamic(
|
||||
/* 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: () => (
|
||||
<div className="space-y-4">
|
||||
@@ -25,9 +26,7 @@ export default function PasswordResetPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Reset your password
|
||||
</h2>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Reset your password</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
We'll send you an email with instructions to reset your password
|
||||
</p>
|
||||
|
||||
@@ -19,9 +19,7 @@ export default function RegisterPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Create your account
|
||||
</h2>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Create your account</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Get started with your free account today
|
||||
</p>
|
||||
|
||||
@@ -15,18 +15,12 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function AuthenticatedLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export default function AuthenticatedLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</AuthGuard>
|
||||
|
||||
@@ -40,11 +40,7 @@ const settingsTabs = [
|
||||
},
|
||||
];
|
||||
|
||||
export default function SettingsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Determine active tab based on pathname
|
||||
@@ -54,12 +50,8 @@ export default function SettingsLayout({
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground">
|
||||
Settings
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Manage your account settings and preferences
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold text-foreground">Settings</h1>
|
||||
<p className="mt-2 text-muted-foreground">Manage your account settings and preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
@@ -79,9 +71,7 @@ export default function SettingsLayout({
|
||||
</TabsList>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="rounded-lg border bg-card text-card-foreground p-6">
|
||||
{children}
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card text-card-foreground p-6">{children}</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,9 +11,7 @@ export default function PasswordSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
Password Settings
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold text-foreground">Password Settings</h2>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Change your password to keep your account secure
|
||||
</p>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* 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 */
|
||||
export const metadata: Metadata = {
|
||||
@@ -14,12 +14,8 @@ export const metadata: Metadata = {
|
||||
export default function PreferencesPage() {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">
|
||||
Preferences
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Configure your preferences (Coming in Task 3.5)
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Preferences</h2>
|
||||
<p className="text-muted-foreground">Configure your preferences (Coming in Task 3.5)</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,8 @@ export default function ProfileSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
Profile Settings
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage your profile information
|
||||
</p>
|
||||
<h2 className="text-2xl font-semibold text-foreground">Profile Settings</h2>
|
||||
<p className="text-muted-foreground mt-1">Manage your profile information</p>
|
||||
</div>
|
||||
|
||||
<ProfileSettingsForm />
|
||||
|
||||
@@ -11,9 +11,7 @@ export default function SessionsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
Active Sessions
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold text-foreground">Active Sessions</h2>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
View and manage devices signed in to your account
|
||||
</p>
|
||||
|
||||
@@ -17,11 +17,7 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthGuard requireAdmin>
|
||||
<a
|
||||
|
||||
@@ -27,9 +27,7 @@ export default function AdminPage() {
|
||||
<div className="space-y-8">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Admin Dashboard</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Manage users, organizations, and system settings
|
||||
</p>
|
||||
@@ -72,9 +70,7 @@ export default function AdminPage() {
|
||||
<Settings className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||
<h3 className="font-semibold">System Settings</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure system-wide settings
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Configure system-wide settings</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -27,9 +27,7 @@ export default function AdminSettingsPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
System Settings
|
||||
</h1>
|
||||
<h1 className="text-3xl font-bold tracking-tight">System Settings</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Configure system-wide settings and preferences
|
||||
</p>
|
||||
@@ -38,16 +36,12 @@ export default function AdminSettingsPage() {
|
||||
|
||||
{/* Placeholder Content */}
|
||||
<div className="rounded-lg border bg-card p-12 text-center">
|
||||
<h3 className="text-xl font-semibold mb-2">
|
||||
System Settings Coming Soon
|
||||
</h3>
|
||||
<h3 className="text-xl font-semibold mb-2">System Settings Coming Soon</h3>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
This page will allow you to configure system-wide settings,
|
||||
preferences, and advanced options.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Features will include:
|
||||
This page will allow you to configure system-wide settings, preferences, and advanced
|
||||
options.
|
||||
</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">
|
||||
<li>• General system configuration</li>
|
||||
<li>• Email and notification settings</li>
|
||||
|
||||
@@ -28,12 +28,8 @@ export default function AdminUsersPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
User Management
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
View, create, and manage user accounts
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">User Management</h1>
|
||||
<p className="mt-2 text-muted-foreground">View, create, and manage user accounts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ import dynamic from 'next/dynamic';
|
||||
const ComponentShowcase = dynamic(
|
||||
() => 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>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ export async function generateStaticParams() {
|
||||
|
||||
try {
|
||||
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$/, '')],
|
||||
}));
|
||||
} catch {
|
||||
@@ -63,12 +63,7 @@ export default async function DocPage({ params }: DocPageProps) {
|
||||
return (
|
||||
<div className="bg-background">
|
||||
{/* Breadcrumbs */}
|
||||
<DevBreadcrumbs
|
||||
items={[
|
||||
{ label: 'Documentation', href: '/dev/docs' },
|
||||
{ label: title }
|
||||
]}
|
||||
/>
|
||||
<DevBreadcrumbs items={[{ label: 'Documentation', href: '/dev/docs' }, { label: title }]} />
|
||||
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
|
||||
@@ -5,7 +5,17 @@
|
||||
*/
|
||||
|
||||
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 { Button } from '@/components/ui/button';
|
||||
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">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<h2 className="text-4xl font-bold tracking-tight mb-4">
|
||||
Design System Documentation
|
||||
</h2>
|
||||
<h2 className="text-4xl font-bold tracking-tight mb-4">Design System Documentation</h2>
|
||||
<p className="text-lg text-muted-foreground mb-8">
|
||||
Comprehensive guides, best practices, and references for building consistent,
|
||||
accessible, and maintainable user interfaces with the FastNext design system.
|
||||
@@ -170,9 +178,7 @@ export default function DocsHub() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
{doc.description}
|
||||
</CardDescription>
|
||||
<CardDescription className="text-base">{doc.description}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
@@ -203,9 +209,7 @@ export default function DocsHub() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription>
|
||||
{doc.description}
|
||||
</CardDescription>
|
||||
<CardDescription>{doc.description}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
@@ -243,9 +247,7 @@ export default function DocsHub() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-base">
|
||||
{doc.description}
|
||||
</CardDescription>
|
||||
<CardDescription className="text-base">{doc.description}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
@@ -254,7 +256,6 @@ export default function DocsHub() {
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,12 +14,7 @@ import { z } from 'zod';
|
||||
import { CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DevBreadcrumbs } from '@/components/dev/DevBreadcrumbs';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -109,9 +104,8 @@ export default function FormsPage() {
|
||||
{/* Introduction */}
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
Complete form implementations using react-hook-form for state management
|
||||
and Zod for validation. Includes error handling, loading states, and
|
||||
accessibility features.
|
||||
Complete form implementations using react-hook-form for state management and Zod for
|
||||
validation. Includes error handling, loading states, and accessibility features.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">react-hook-form</Badge>
|
||||
@@ -170,16 +164,10 @@ const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
placeholder="you@example.com"
|
||||
{...registerLogin('email')}
|
||||
aria-invalid={!!errorsLogin.email}
|
||||
aria-describedby={
|
||||
errorsLogin.email ? 'login-email-error' : undefined
|
||||
}
|
||||
aria-describedby={errorsLogin.email ? 'login-email-error' : undefined}
|
||||
/>
|
||||
{errorsLogin.email && (
|
||||
<p
|
||||
id="login-email-error"
|
||||
className="text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<p id="login-email-error" className="text-sm text-destructive" role="alert">
|
||||
{errorsLogin.email.message}
|
||||
</p>
|
||||
)}
|
||||
@@ -194,9 +182,7 @@ const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
placeholder="••••••••"
|
||||
{...registerLogin('password')}
|
||||
aria-invalid={!!errorsLogin.password}
|
||||
aria-describedby={
|
||||
errorsLogin.password ? 'login-password-error' : undefined
|
||||
}
|
||||
aria-describedby={errorsLogin.password ? 'login-password-error' : undefined}
|
||||
/>
|
||||
{errorsLogin.password && (
|
||||
<p
|
||||
@@ -277,10 +263,7 @@ const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
</form>`}
|
||||
>
|
||||
<div className="max-w-md mx-auto">
|
||||
<form
|
||||
onSubmit={handleSubmitContact(onSubmitContact)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<form onSubmit={handleSubmitContact(onSubmitContact)} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-name">Name</Label>
|
||||
@@ -317,9 +300,7 @@ const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
{/* Category */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-category">Category</Label>
|
||||
<Select
|
||||
onValueChange={(value) => setValueContact('category', value)}
|
||||
>
|
||||
<Select onValueChange={(value) => setValueContact('category', value)}>
|
||||
<SelectTrigger id="contact-category">
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</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">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<AlertTitle>Success!</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your message has been sent successfully.
|
||||
</AlertDescription>
|
||||
<AlertDescription>Your message has been sent successfully.</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</form>
|
||||
@@ -384,7 +363,7 @@ const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
title="Error State Best Practices"
|
||||
description="Use aria-invalid and aria-describedby for accessibility"
|
||||
before={{
|
||||
caption: "No ARIA attributes, poor accessibility",
|
||||
caption: 'No ARIA attributes, poor accessibility',
|
||||
content: (
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<div className="text-sm font-medium">Email</div>
|
||||
@@ -394,7 +373,7 @@ const { register, handleSubmit, formState: { errors } } = useForm({
|
||||
),
|
||||
}}
|
||||
after={{
|
||||
caption: "With ARIA, screen reader accessible",
|
||||
caption: 'With ARIA, screen reader accessible',
|
||||
content: (
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<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">
|
||||
<li className="flex items-start gap-2">
|
||||
<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 className="flex items-start gap-2">
|
||||
<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 className="flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5" />
|
||||
<span>Add <code className="text-xs">role="alert"</code> to error messages</span>
|
||||
<span>
|
||||
Add <code className="text-xs">role="alert"</code> to error messages
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<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="font-medium text-sm">Optional Field</div>
|
||||
<code className="block rounded bg-muted p-2 text-xs">
|
||||
z.string().optional()
|
||||
</code>
|
||||
<code className="block rounded bg-muted p-2 text-xs">z.string().optional()</code>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -9,13 +9,7 @@ import Link from 'next/link';
|
||||
import { Grid3x3 } from 'lucide-react';
|
||||
import { DevBreadcrumbs } from '@/components/dev/DevBreadcrumbs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Example, ExampleSection } from '@/components/dev/Example';
|
||||
import { BeforeAfter } from '@/components/dev/BeforeAfter';
|
||||
@@ -37,9 +31,8 @@ export default function LayoutsPage() {
|
||||
{/* Introduction */}
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
These 5 essential layout patterns cover 80% of interface needs. Each
|
||||
pattern includes live examples, before/after comparisons, and copy-paste
|
||||
code.
|
||||
These 5 essential layout patterns cover 80% of interface needs. Each pattern includes
|
||||
live examples, before/after comparisons, and copy-paste code.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">Grid vs Flex</Badge>
|
||||
@@ -79,14 +72,12 @@ export default function LayoutsPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Content Card</CardTitle>
|
||||
<CardDescription>
|
||||
Constrained to max-w-4xl for readability
|
||||
</CardDescription>
|
||||
<CardDescription>Constrained to max-w-4xl for readability</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your main content goes here. The max-w-4xl constraint
|
||||
ensures comfortable reading width.
|
||||
Your main content goes here. The max-w-4xl constraint ensures comfortable
|
||||
reading width.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -99,25 +90,24 @@ export default function LayoutsPage() {
|
||||
title="Common Mistake: No Width Constraint"
|
||||
description="Content should not span full viewport width"
|
||||
before={{
|
||||
caption: "No max-width, hard to read on wide screens",
|
||||
caption: 'No max-width, hard to read on wide screens',
|
||||
content: (
|
||||
<div className="w-full space-y-4 bg-background p-4 rounded">
|
||||
<h3 className="font-semibold">Full Width Content</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This text spans the entire width, making it hard to read on
|
||||
large screens. Lines become too long.
|
||||
This text spans the entire width, making it hard to read on large screens.
|
||||
Lines become too long.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
after={{
|
||||
caption: "Constrained with max-w for better readability",
|
||||
caption: 'Constrained with max-w for better readability',
|
||||
content: (
|
||||
<div className="max-w-2xl mx-auto space-y-4 bg-background p-4 rounded">
|
||||
<h3 className="font-semibold">Constrained Content</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This text has a max-width, creating comfortable line lengths
|
||||
for reading.
|
||||
This text has a max-width, creating comfortable line lengths for reading.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
@@ -149,14 +139,10 @@ export default function LayoutsPage() {
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Metric {i}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">Metric {i}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{(Math.random() * 1000).toFixed(0)}
|
||||
</div>
|
||||
<div className="text-2xl font-bold">{(Math.random() * 1000).toFixed(0)}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
+{(Math.random() * 20).toFixed(1)}% from last month
|
||||
</p>
|
||||
@@ -170,7 +156,7 @@ export default function LayoutsPage() {
|
||||
title="Grid vs Flex for Equal Columns"
|
||||
description="Use Grid for equal-width columns, not Flex"
|
||||
before={{
|
||||
caption: "flex with flex-1 - uneven wrapping",
|
||||
caption: 'flex with flex-1 - uneven wrapping',
|
||||
content: (
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px] rounded border bg-background p-4">
|
||||
@@ -186,7 +172,7 @@ export default function LayoutsPage() {
|
||||
),
|
||||
}}
|
||||
after={{
|
||||
caption: "grid with grid-cols - consistent sizing",
|
||||
caption: 'grid with grid-cols - consistent sizing',
|
||||
content: (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="rounded border bg-background p-4">
|
||||
@@ -231,9 +217,7 @@ export default function LayoutsPage() {
|
||||
<Card className="max-w-md mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Login</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your credentials to continue
|
||||
</CardDescription>
|
||||
<CardDescription>Enter your credentials to continue</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4">
|
||||
@@ -288,10 +272,7 @@ export default function LayoutsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{['Dashboard', 'Settings', 'Profile'].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="rounded-md bg-muted px-3 py-2 text-sm"
|
||||
>
|
||||
<div key={item} className="rounded-md bg-muted px-3 py-2 text-sm">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
@@ -304,14 +285,12 @@ export default function LayoutsPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Main Content</CardTitle>
|
||||
<CardDescription>
|
||||
Fixed 240px sidebar, fluid main area
|
||||
</CardDescription>
|
||||
<CardDescription>Fixed 240px sidebar, fluid main area</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The sidebar remains 240px wide while the main content area
|
||||
flexes to fill remaining space.
|
||||
The sidebar remains 240px wide while the main content area flexes to fill
|
||||
remaining space.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -344,9 +323,7 @@ export default function LayoutsPage() {
|
||||
<Card className="max-w-sm w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Centered Card</CardTitle>
|
||||
<CardDescription>
|
||||
Centered vertically and horizontally
|
||||
</CardDescription>
|
||||
<CardDescription>Centered vertically and horizontally</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -422,9 +399,7 @@ export default function LayoutsPage() {
|
||||
<CardDescription>Most common pattern</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<code className="text-xs">
|
||||
grid-cols-1 md:grid-cols-2 lg:grid-cols-3
|
||||
</code>
|
||||
<code className="text-xs">grid-cols-1 md:grid-cols-2 lg:grid-cols-3</code>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Mobile: 1 column
|
||||
<br />
|
||||
@@ -441,9 +416,7 @@ export default function LayoutsPage() {
|
||||
<CardDescription>For smaller cards</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<code className="text-xs">
|
||||
grid-cols-1 md:grid-cols-2 lg:grid-cols-4
|
||||
</code>
|
||||
<code className="text-xs">grid-cols-1 md:grid-cols-2 lg:grid-cols-4</code>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Mobile: 1 column
|
||||
<br />
|
||||
@@ -475,9 +448,7 @@ export default function LayoutsPage() {
|
||||
<CardDescription>Mobile navigation</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<code className="text-xs">
|
||||
hidden lg:block
|
||||
</code>
|
||||
<code className="text-xs">hidden lg:block</code>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Mobile: Hidden (use menu)
|
||||
<br />
|
||||
|
||||
@@ -6,23 +6,9 @@
|
||||
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Palette,
|
||||
Layout,
|
||||
Ruler,
|
||||
FileText,
|
||||
BookOpen,
|
||||
ArrowRight,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Palette, Layout, Ruler, FileText, BookOpen, ArrowRight, Sparkles } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
@@ -98,13 +84,11 @@ export default function DesignSystemHub() {
|
||||
<div className="space-y-4 max-w-3xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-8 w-8 text-primary" />
|
||||
<h1 className="text-4xl font-bold tracking-tight">
|
||||
Design System Hub
|
||||
</h1>
|
||||
<h1 className="text-4xl font-bold tracking-tight">Design System Hub</h1>
|
||||
</div>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Interactive demonstrations, live examples, and comprehensive documentation for
|
||||
the FastNext design system. Built with shadcn/ui + Tailwind CSS 4.
|
||||
Interactive demonstrations, live examples, and comprehensive documentation for the
|
||||
FastNext design system. Built with shadcn/ui + Tailwind CSS 4.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,9 +100,7 @@ export default function DesignSystemHub() {
|
||||
{/* Demo Pages Grid */}
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Interactive Demonstrations
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Interactive Demonstrations</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Explore live examples with copy-paste code snippets
|
||||
</p>
|
||||
@@ -143,9 +125,7 @@ export default function DesignSystemHub() {
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
{page.status === 'enhanced' && (
|
||||
<Badge variant="secondary">Enhanced</Badge>
|
||||
)}
|
||||
{page.status === 'enhanced' && <Badge variant="secondary">Enhanced</Badge>}
|
||||
</div>
|
||||
<CardTitle className="mt-4">{page.title}</CardTitle>
|
||||
<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">
|
||||
{documentationLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="group"
|
||||
>
|
||||
<Link key={link.href} href={link.href} className="group">
|
||||
<Card className="h-full transition-all hover:border-primary/50 hover:bg-accent/50">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-base group-hover:text-primary transition-colors">
|
||||
{link.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{link.description}
|
||||
</CardDescription>
|
||||
<CardDescription className="text-xs">{link.description}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
@@ -214,9 +188,7 @@ export default function DesignSystemHub() {
|
||||
|
||||
{/* Key Features */}
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Key Features
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Key Features</h2>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card>
|
||||
|
||||
@@ -10,13 +10,7 @@ import Link from 'next/link';
|
||||
import { Ruler } from 'lucide-react';
|
||||
import { DevBreadcrumbs } from '@/components/dev/DevBreadcrumbs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
// Code-split heavy dev components
|
||||
@@ -52,9 +46,9 @@ export default function SpacingPage() {
|
||||
{/* Introduction */}
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
The Golden Rule: <strong>Parents control spacing, not children.</strong>{' '}
|
||||
Use gap, space-y, and space-x utilities on the parent container. Avoid
|
||||
margins on children except for exceptions.
|
||||
The Golden Rule: <strong>Parents control spacing, not children.</strong> Use gap,
|
||||
space-y, and space-x utilities on the parent container. Avoid margins on children
|
||||
except for exceptions.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">gap</Badge>
|
||||
@@ -73,9 +67,7 @@ export default function SpacingPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Common Spacing Values</CardTitle>
|
||||
<CardDescription>
|
||||
Use consistent spacing values from the scale
|
||||
</CardDescription>
|
||||
<CardDescription>Use consistent spacing values from the scale</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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">{item.use}</span>
|
||||
<div className="col-span-4">
|
||||
<div
|
||||
className="h-2 rounded bg-primary"
|
||||
style={{ width: item.px }}
|
||||
></div>
|
||||
<div className="h-2 rounded bg-primary" style={{ width: item.px }}></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -158,10 +147,7 @@ export default function SpacingPage() {
|
||||
<p className="text-sm font-medium mb-2">Grid (gap-6)</p>
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border bg-muted p-3 text-center text-sm"
|
||||
>
|
||||
<div key={i} className="rounded-lg border bg-muted p-3 text-center text-sm">
|
||||
Card {i}
|
||||
</div>
|
||||
))}
|
||||
@@ -207,12 +193,8 @@ export default function SpacingPage() {
|
||||
<div className="rounded-lg border bg-muted p-3 text-sm">
|
||||
First item (no margin)
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted p-3 text-sm">
|
||||
Second item (mt-4)
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted p-3 text-sm">
|
||||
Third item (mt-4)
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted p-3 text-sm">Second item (mt-4)</div>
|
||||
<div className="rounded-lg border bg-muted p-3 text-sm">Third item (mt-4)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -245,7 +227,7 @@ export default function SpacingPage() {
|
||||
title="Don't Let Children Control Spacing"
|
||||
description="Parent should control spacing, not children"
|
||||
before={{
|
||||
caption: "Children control their own spacing with mt-4",
|
||||
caption: 'Children control their own spacing with mt-4',
|
||||
content: (
|
||||
<div className="space-y-2 rounded-lg border p-4">
|
||||
<div className="rounded bg-muted p-2 text-xs">
|
||||
@@ -264,14 +246,12 @@ export default function SpacingPage() {
|
||||
),
|
||||
}}
|
||||
after={{
|
||||
caption: "Parent controls spacing with space-y-4",
|
||||
caption: 'Parent controls spacing with space-y-4',
|
||||
content: (
|
||||
<div className="space-y-4 rounded-lg border p-4">
|
||||
<div className="rounded bg-muted p-2 text-xs">
|
||||
<div>Child 1</div>
|
||||
<code className="text-[10px] text-green-600">
|
||||
parent uses space-y-4
|
||||
</code>
|
||||
<code className="text-[10px] text-green-600">parent uses space-y-4</code>
|
||||
</div>
|
||||
<div className="rounded bg-muted p-2 text-xs">
|
||||
<div>Child 2</div>
|
||||
@@ -290,7 +270,7 @@ export default function SpacingPage() {
|
||||
title="Use Gap, Not Margin for Buttons"
|
||||
description="Button groups should use gap, not margins"
|
||||
before={{
|
||||
caption: "Margin on children - harder to maintain",
|
||||
caption: 'Margin on children - harder to maintain',
|
||||
content: (
|
||||
<div className="flex rounded-lg border p-4">
|
||||
<Button variant="outline" size="sm">
|
||||
@@ -303,7 +283,7 @@ export default function SpacingPage() {
|
||||
),
|
||||
}}
|
||||
after={{
|
||||
caption: "Gap on parent - clean and flexible",
|
||||
caption: 'Gap on parent - clean and flexible',
|
||||
content: (
|
||||
<div className="flex gap-4 rounded-lg border p-4">
|
||||
<Button variant="outline" size="sm">
|
||||
|
||||
@@ -20,23 +20,18 @@ export default function ForbiddenPage() {
|
||||
<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="mb-8 rounded-full bg-destructive/10 p-6">
|
||||
<ShieldAlert
|
||||
className="h-16 w-16 text-destructive"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ShieldAlert className="h-16 w-16 text-destructive" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<h1 className="mb-4 text-4xl font-bold tracking-tight">
|
||||
403 - Access Forbidden
|
||||
</h1>
|
||||
<h1 className="mb-4 text-4xl font-bold tracking-tight">403 - Access Forbidden</h1>
|
||||
|
||||
<p className="mb-2 text-lg text-muted-foreground max-w-md">
|
||||
You don't have permission to access this resource.
|
||||
</p>
|
||||
|
||||
<p className="mb-8 text-sm text-muted-foreground max-w-md">
|
||||
This page requires administrator privileges. If you believe you should
|
||||
have access, please contact your system administrator.
|
||||
This page requires administrator privileges. If you believe you should have access, please
|
||||
contact your system administrator.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
|
||||
/**
|
||||
* FastNext Template Design System
|
||||
@@ -11,38 +11,38 @@
|
||||
|
||||
:root {
|
||||
/* Colors */
|
||||
--background: oklch(1.0000 0 0);
|
||||
--background: oklch(1 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);
|
||||
--popover: oklch(1.0000 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.3211 0 0);
|
||||
--primary: oklch(0.6231 0.1880 259.8145);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--secondary: oklch(0.9670 0.0029 264.5419);
|
||||
--primary: oklch(0.6231 0.188 259.8145);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.967 0.0029 264.5419);
|
||||
--secondary-foreground: oklch(0.4461 0.0263 256.8018);
|
||||
--muted: oklch(0.9846 0.0017 247.8389);
|
||||
--muted-foreground: oklch(0.5510 0.0234 264.3637);
|
||||
--accent: oklch(0.9514 0.0250 236.8242);
|
||||
--muted-foreground: oklch(0.551 0.0234 264.3637);
|
||||
--accent: oklch(0.9514 0.025 236.8242);
|
||||
--accent-foreground: oklch(0.3791 0.1378 265.5222);
|
||||
--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);
|
||||
--input: oklch(0.9276 0.0058 264.5313);
|
||||
--ring: oklch(0.6231 0.1880 259.8145);
|
||||
--chart-1: oklch(0.6231 0.1880 259.8145);
|
||||
--ring: oklch(0.6231 0.188 259.8145);
|
||||
--chart-1: oklch(0.6231 0.188 259.8145);
|
||||
--chart-2: oklch(0.5461 0.2152 262.8809);
|
||||
--chart-3: oklch(0.4882 0.2172 264.3763);
|
||||
--chart-4: oklch(0.4244 0.1809 265.6377);
|
||||
--chart-5: oklch(0.3791 0.1378 265.5222);
|
||||
--sidebar: oklch(0.9846 0.0017 247.8389);
|
||||
--sidebar-foreground: oklch(0.3211 0 0);
|
||||
--sidebar-primary: oklch(0.6231 0.1880 259.8145);
|
||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-accent: oklch(0.9514 0.0250 236.8242);
|
||||
--sidebar-primary: oklch(0.6231 0.188 259.8145);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.9514 0.025 236.8242);
|
||||
--sidebar-accent-foreground: oklch(0.3791 0.1378 265.5222);
|
||||
--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 */
|
||||
--font-sans: var(--font-geist-sans), system-ui, -apple-system, sans-serif;
|
||||
@@ -61,11 +61,11 @@
|
||||
--shadow-color: oklch(0 0 0);
|
||||
--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-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1);
|
||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||
|
||||
/* Spacing */
|
||||
@@ -81,8 +81,8 @@
|
||||
--card-foreground: oklch(0.9219 0 0);
|
||||
--popover: oklch(0.2686 0 0);
|
||||
--popover-foreground: oklch(0.9219 0 0);
|
||||
--primary: oklch(0.6231 0.1880 259.8145);
|
||||
--primary-foreground: oklch(1.0000 0 0);
|
||||
--primary: oklch(0.6231 0.188 259.8145);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.2686 0 0);
|
||||
--secondary-foreground: oklch(0.9219 0 0);
|
||||
--muted: oklch(0.2393 0 0);
|
||||
@@ -90,23 +90,23 @@
|
||||
--accent: oklch(0.3791 0.1378 265.5222);
|
||||
--accent-foreground: oklch(0.8823 0.0571 254.1284);
|
||||
--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);
|
||||
--input: oklch(0.3715 0 0);
|
||||
--ring: oklch(0.6231 0.1880 259.8145);
|
||||
--chart-1: oklch(0.7137 0.1434 254.6240);
|
||||
--chart-2: oklch(0.6231 0.1880 259.8145);
|
||||
--ring: oklch(0.6231 0.188 259.8145);
|
||||
--chart-1: oklch(0.7137 0.1434 254.624);
|
||||
--chart-2: oklch(0.6231 0.188 259.8145);
|
||||
--chart-3: oklch(0.5461 0.2152 262.8809);
|
||||
--chart-4: oklch(0.4882 0.2172 264.3763);
|
||||
--chart-5: oklch(0.4244 0.1809 265.6377);
|
||||
--sidebar: oklch(0.2046 0 0);
|
||||
--sidebar-foreground: oklch(0.9219 0 0);
|
||||
--sidebar-primary: oklch(0.6231 0.1880 259.8145);
|
||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||
--sidebar-primary: oklch(0.6231 0.188 259.8145);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.3791 0.1378 265.5222);
|
||||
--sidebar-accent-foreground: oklch(0.8823 0.0571 254.1284);
|
||||
--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 */
|
||||
@@ -186,24 +186,24 @@ html.dark {
|
||||
|
||||
/* Cursor pointer for all clickable elements */
|
||||
button,
|
||||
[role="button"],
|
||||
[type="button"],
|
||||
[type="submit"],
|
||||
[type="reset"],
|
||||
[role='button'],
|
||||
[type='button'],
|
||||
[type='submit'],
|
||||
[type='reset'],
|
||||
a,
|
||||
label[for],
|
||||
select,
|
||||
[tabindex]:not([tabindex="-1"]) {
|
||||
[tabindex]:not([tabindex='-1']) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Exception: disabled elements should not have pointer cursor */
|
||||
button:disabled,
|
||||
[role="button"][aria-disabled="true"],
|
||||
[type="button"]:disabled,
|
||||
[type="submit"]:disabled,
|
||||
[type="reset"]:disabled,
|
||||
a[aria-disabled="true"],
|
||||
[role='button'][aria-disabled='true'],
|
||||
[type='button']:disabled,
|
||||
[type='submit']:disabled,
|
||||
[type='reset']:disabled,
|
||||
a[aria-disabled='true'],
|
||||
select:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
import { AuthProvider } from "@/lib/auth/AuthContext";
|
||||
import { AuthInitializer } from "@/components/auth";
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
import { AuthProvider } from '@/lib/auth/AuthContext';
|
||||
import { AuthInitializer } from '@/components/auth';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
display: "swap", // Prevent font from blocking render
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
display: 'swap', // Prevent font from blocking render
|
||||
preload: true,
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
display: "swap", // Prevent font from blocking render
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
display: 'swap', // Prevent font from blocking render
|
||||
preload: false, // Only preload primary font
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "FastNext Template",
|
||||
description: "FastAPI + Next.js Template",
|
||||
title: 'FastNext Template',
|
||||
description: 'FastAPI + Next.js Template',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -57,9 +57,7 @@ export default function RootLayout({
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||
<AuthProvider>
|
||||
<AuthInitializer />
|
||||
<Providers>{children}</Providers>
|
||||
|
||||
@@ -99,10 +99,7 @@ export default function Home() {
|
||||
</footer>
|
||||
|
||||
{/* Shared Demo Credentials Modal */}
|
||||
<DemoCredentialsModal
|
||||
open={demoModalOpen}
|
||||
onClose={() => setDemoModalOpen(false)}
|
||||
/>
|
||||
<DemoCredentialsModal open={demoModalOpen} onClose={() => setDemoModalOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ import { ThemeProvider } from '@/components/theme';
|
||||
// Set NEXT_PUBLIC_ENABLE_DEVTOOLS=true in .env.local to enable
|
||||
/* istanbul ignore next - Dev-only devtools, not tested in production */
|
||||
const ReactQueryDevtools =
|
||||
process.env.NODE_ENV === 'development' &&
|
||||
process.env.NEXT_PUBLIC_ENABLE_DEVTOOLS === 'true'
|
||||
process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_ENABLE_DEVTOOLS === 'true'
|
||||
? lazy(() =>
|
||||
import('@tanstack/react-query-devtools').then((mod) => ({
|
||||
default: mod.ReactQueryDevtools,
|
||||
|
||||
@@ -64,9 +64,7 @@ export function AdminSidebar() {
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sidebar Header */}
|
||||
<div className="flex h-16 items-center justify-between border-b px-4">
|
||||
{!collapsed && (
|
||||
<h2 className="text-lg font-semibold">Admin Panel</h2>
|
||||
)}
|
||||
{!collapsed && <h2 className="text-lg font-semibold">Admin Panel</h2>}
|
||||
<button
|
||||
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"
|
||||
@@ -85,8 +83,7 @@ export function AdminSidebar() {
|
||||
<nav className="flex-1 space-y-1 p-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(item.href !== '/admin' && pathname.startsWith(item.href));
|
||||
pathname === item.href || (item.href !== '/admin' && pathname.startsWith(item.href));
|
||||
const Icon = item.icon;
|
||||
|
||||
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',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground',
|
||||
isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground',
|
||||
collapsed && 'justify-center'
|
||||
)}
|
||||
title={collapsed ? item.name : undefined}
|
||||
@@ -123,9 +118,7 @@ export function AdminSidebar() {
|
||||
<p className="text-sm font-medium truncate">
|
||||
{user.first_name} {user.last_name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,10 +61,7 @@ export function Breadcrumbs() {
|
||||
return (
|
||||
<li key={breadcrumb.href} className="flex items-center">
|
||||
{index > 0 && (
|
||||
<ChevronRight
|
||||
className="mx-2 h-4 w-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronRight className="mx-2 h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
{isLast ? (
|
||||
<span
|
||||
|
||||
@@ -26,10 +26,7 @@ export function DashboardStats() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"
|
||||
data-testid="dashboard-stats"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4" data-testid="dashboard-stats">
|
||||
<StatCard
|
||||
title="Total Users"
|
||||
value={stats?.totalUsers ?? 0}
|
||||
|
||||
@@ -40,29 +40,20 @@ export function StatCard({
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1 flex-1">
|
||||
<p
|
||||
className="text-sm font-medium text-muted-foreground"
|
||||
data-testid="stat-title"
|
||||
>
|
||||
<p className="text-sm font-medium text-muted-foreground" data-testid="stat-title">
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
{loading ? (
|
||||
<div className="h-8 w-24 bg-muted rounded" />
|
||||
) : (
|
||||
<p
|
||||
className="text-3xl font-bold tracking-tight"
|
||||
data-testid="stat-value"
|
||||
>
|
||||
<p className="text-3xl font-bold tracking-tight" data-testid="stat-value">
|
||||
{value}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{description && !loading && (
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid="stat-description"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground" data-testid="stat-description">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
@@ -74,22 +65,13 @@ export function StatCard({
|
||||
)}
|
||||
data-testid="stat-trend"
|
||||
>
|
||||
{trend.isPositive ? '↑' : '↓'} {Math.abs(trend.value)}%{' '}
|
||||
{trend.label}
|
||||
{trend.isPositive ? '↑' : '↓'} {Math.abs(trend.value)}% {trend.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full p-3',
|
||||
loading ? 'bg-muted' : 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div className={cn('rounded-full p-3', loading ? 'bg-muted' : 'bg-primary/10')}>
|
||||
<Icon
|
||||
className={cn(
|
||||
'h-6 w-6',
|
||||
loading ? 'text-muted-foreground' : 'text-primary'
|
||||
)}
|
||||
className={cn('h-6 w-6', loading ? 'text-muted-foreground' : 'text-primary')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -47,11 +47,7 @@ interface AddMemberDialogProps {
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export function AddMemberDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
organizationId,
|
||||
}: AddMemberDialogProps) {
|
||||
export function AddMemberDialog({ open, onOpenChange, organizationId }: AddMemberDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 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 selectedEmail = watch('userEmail');
|
||||
|
||||
@@ -139,7 +140,12 @@ export function AddMemberDialog({
|
||||
{/* Role Select */}
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
@@ -150,9 +156,7 @@ export function AddMemberDialog({
|
||||
<SelectItem value="guest">Guest</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.role && (
|
||||
<p className="text-sm text-destructive">{errors.role.message}</p>
|
||||
)}
|
||||
{errors.role && <p className="text-sm text-destructive">{errors.role.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -25,20 +25,14 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
useRemoveOrganizationMember,
|
||||
type OrganizationMember,
|
||||
} from '@/lib/api/hooks/useAdmin';
|
||||
import { useRemoveOrganizationMember, type OrganizationMember } from '@/lib/api/hooks/useAdmin';
|
||||
|
||||
interface MemberActionMenuProps {
|
||||
member: OrganizationMember;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export function MemberActionMenu({
|
||||
member,
|
||||
organizationId,
|
||||
}: MemberActionMenuProps) {
|
||||
export function MemberActionMenu({ member, organizationId }: MemberActionMenuProps) {
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
@@ -59,9 +53,8 @@ export function MemberActionMenu({
|
||||
}
|
||||
};
|
||||
|
||||
const memberName = [member.first_name, member.last_name]
|
||||
.filter(Boolean)
|
||||
.join(' ') || member.email;
|
||||
const memberName =
|
||||
[member.first_name, member.last_name].filter(Boolean).join(' ') || member.email;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -93,8 +86,8 @@ export function MemberActionMenu({
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Member</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove {memberName} from this organization?
|
||||
This action cannot be undone.
|
||||
Are you sure you want to remove {memberName} from this organization? This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -26,10 +26,7 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
useDeleteOrganization,
|
||||
type Organization,
|
||||
} from '@/lib/api/hooks/useAdmin';
|
||||
import { useDeleteOrganization, type Organization } from '@/lib/api/hooks/useAdmin';
|
||||
|
||||
interface OrganizationActionMenuProps {
|
||||
organization: Organization;
|
||||
@@ -115,8 +112,8 @@ export function OrganizationActionMenu({
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Organization</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete {organization.name}? This action cannot be undone
|
||||
and will remove all associated data.
|
||||
Are you sure you want to delete {organization.name}? This action cannot be undone and
|
||||
will remove all associated data.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -112,7 +112,10 @@ export function OrganizationFormDialog({
|
||||
toast.success(`${data.name} has been updated successfully.`);
|
||||
} else {
|
||||
// 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({
|
||||
name: data.name,
|
||||
@@ -125,7 +128,9 @@ export function OrganizationFormDialog({
|
||||
form.reset();
|
||||
} catch (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}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? 'Edit Organization' : 'Create Organization'}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{isEdit ? 'Edit Organization' : 'Create Organization'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? 'Update the organization details below.'
|
||||
@@ -189,15 +192,10 @@ export function OrganizationFormDialog({
|
||||
<Checkbox
|
||||
id="is_active"
|
||||
checked={form.watch('is_active')}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue('is_active', checked === true)
|
||||
}
|
||||
onCheckedChange={(checked) => form.setValue('is_active', checked === true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="is_active"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
<Label htmlFor="is_active" className="text-sm font-normal cursor-pointer">
|
||||
Organization is active
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
@@ -93,9 +93,7 @@ export function OrganizationListTable({
|
||||
<TableCell className="font-medium">{org.name}</TableCell>
|
||||
<TableCell className="max-w-md truncate">
|
||||
{org.description || (
|
||||
<span className="text-muted-foreground italic">
|
||||
No description
|
||||
</span>
|
||||
<span className="text-muted-foreground italic">No description</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
@@ -112,9 +110,7 @@ export function OrganizationListTable({
|
||||
{org.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(org.created_at), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>{format(new Date(org.created_at), 'MMM d, yyyy')}</TableCell>
|
||||
<TableCell>
|
||||
<OrganizationActionMenu
|
||||
organization={org}
|
||||
@@ -135,11 +131,8 @@ export function OrganizationListTable({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.page_size + 1} to{' '}
|
||||
{Math.min(
|
||||
pagination.page * pagination.page_size,
|
||||
pagination.total
|
||||
)}{' '}
|
||||
of {pagination.total} organizations
|
||||
{Math.min(pagination.page * pagination.page_size, pagination.total)} of{' '}
|
||||
{pagination.total} organizations
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -164,13 +157,9 @@ export function OrganizationListTable({
|
||||
|
||||
return (
|
||||
<div key={page} className="flex items-center">
|
||||
{showEllipsis && (
|
||||
<span className="px-2 text-muted-foreground">...</span>
|
||||
)}
|
||||
{showEllipsis && <span className="px-2 text-muted-foreground">...</span>}
|
||||
<Button
|
||||
variant={
|
||||
page === pagination.page ? 'default' : 'outline'
|
||||
}
|
||||
variant={page === pagination.page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page)}
|
||||
className="w-9"
|
||||
|
||||
@@ -89,9 +89,7 @@ export function OrganizationManagementContent() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">All Organizations</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage organizations and their members
|
||||
</p>
|
||||
<p className="text-muted-foreground">Manage organizations and their members</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateOrganization}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -85,9 +85,7 @@ export function OrganizationMembersContent({ organizationId }: OrganizationMembe
|
||||
{/* Header with Add Member Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{orgName} Members
|
||||
</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{orgName} Members</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage members and their roles within the organization
|
||||
</p>
|
||||
|
||||
@@ -119,14 +119,9 @@ export function OrganizationMembersTable({
|
||||
{formatRole(member.role)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{format(new Date(member.joined_at), 'MMM d, yyyy')}</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(member.joined_at), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MemberActionMenu
|
||||
member={member}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
<MemberActionMenu member={member} organizationId={organizationId} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -141,11 +136,8 @@ export function OrganizationMembersTable({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.page_size + 1} to{' '}
|
||||
{Math.min(
|
||||
pagination.page * pagination.page_size,
|
||||
pagination.total
|
||||
)}{' '}
|
||||
of {pagination.total} members
|
||||
{Math.min(pagination.page * pagination.page_size, pagination.total)} of{' '}
|
||||
{pagination.total} members
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -170,13 +162,9 @@ export function OrganizationMembersTable({
|
||||
|
||||
return (
|
||||
<div key={page} className="flex items-center">
|
||||
{showEllipsis && (
|
||||
<span className="px-2 text-muted-foreground">...</span>
|
||||
)}
|
||||
{showEllipsis && <span className="px-2 text-muted-foreground">...</span>}
|
||||
<Button
|
||||
variant={
|
||||
page === pagination.page ? 'default' : 'outline'
|
||||
}
|
||||
variant={page === pagination.page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page)}
|
||||
className="w-9"
|
||||
|
||||
@@ -62,9 +62,7 @@ export function BulkActionToolbar({
|
||||
onClearSelection();
|
||||
setPendingAction(null);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : `Failed to ${pendingAction} users`
|
||||
);
|
||||
toast.error(error instanceof Error ? error.message : `Failed to ${pendingAction} users`);
|
||||
setPendingAction(null);
|
||||
}
|
||||
};
|
||||
@@ -161,9 +159,7 @@ export function BulkActionToolbar({
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{getActionTitle()}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{getActionDescription()}
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogDescription>{getActionDescription()}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
|
||||
@@ -49,9 +49,7 @@ export function UserActionMenu({ user, isCurrentUser, onEdit }: UserActionMenuPr
|
||||
const deactivateUser = useDeactivateUser();
|
||||
const deleteUser = useDeleteUser();
|
||||
|
||||
const fullName = user.last_name
|
||||
? `${user.first_name} ${user.last_name}`
|
||||
: user.first_name;
|
||||
const fullName = user.last_name ? `${user.first_name} ${user.last_name}` : user.first_name;
|
||||
|
||||
// Handle activate action
|
||||
const handleActivate = async () => {
|
||||
|
||||
@@ -23,21 +23,14 @@ import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Alert } from '@/components/ui/alert';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
useCreateUser,
|
||||
useUpdateUser,
|
||||
type User,
|
||||
} from '@/lib/api/hooks/useAdmin';
|
||||
import { useCreateUser, useUpdateUser, type User } from '@/lib/api/hooks/useAdmin';
|
||||
|
||||
// ============================================================================
|
||||
// Validation Schema
|
||||
// ============================================================================
|
||||
|
||||
const userFormSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, 'Email is required')
|
||||
.email('Please enter a valid email address'),
|
||||
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
|
||||
first_name: z
|
||||
.string()
|
||||
.min(1, 'First name is required')
|
||||
@@ -66,12 +59,7 @@ interface UserFormDialogProps {
|
||||
mode: 'create' | 'edit';
|
||||
}
|
||||
|
||||
export function UserFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
user,
|
||||
mode,
|
||||
}: UserFormDialogProps) {
|
||||
export function UserFormDialog({ open, onOpenChange, user, mode }: UserFormDialogProps) {
|
||||
const isEdit = mode === 'edit' && user;
|
||||
const createUser = useCreateUser();
|
||||
const updateUser = useUpdateUser();
|
||||
@@ -130,7 +118,9 @@ export function UserFormDialog({
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -147,7 +137,9 @@ export function UserFormDialog({
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -305,10 +297,7 @@ export function UserFormDialog({
|
||||
onCheckedChange={(checked) => setValue('is_active', checked as boolean)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="is_active"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
<Label htmlFor="is_active" className="text-sm font-normal cursor-pointer">
|
||||
Active (user can log in)
|
||||
</Label>
|
||||
</div>
|
||||
@@ -320,10 +309,7 @@ export function UserFormDialog({
|
||||
onCheckedChange={(checked) => setValue('is_superuser', checked as boolean)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="is_superuser"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
<Label htmlFor="is_superuser" className="text-sm font-normal cursor-pointer">
|
||||
Superuser (admin privileges)
|
||||
</Label>
|
||||
</div>
|
||||
@@ -335,8 +321,8 @@ export function UserFormDialog({
|
||||
{createUser.isError && createUser.error instanceof Error
|
||||
? createUser.error.message
|
||||
: updateUser.error instanceof Error
|
||||
? updateUser.error.message
|
||||
: 'An error occurred'}
|
||||
? updateUser.error.message
|
||||
: 'An error occurred'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -355,8 +341,8 @@ export function UserFormDialog({
|
||||
? 'Updating...'
|
||||
: 'Creating...'
|
||||
: isEdit
|
||||
? 'Update User'
|
||||
: 'Create User'}
|
||||
? 'Update User'
|
||||
: 'Create User'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -74,8 +74,7 @@ export function UserListTable({
|
||||
[onSearch]
|
||||
);
|
||||
|
||||
const allSelected =
|
||||
users.length > 0 && users.every((user) => selectedUsers.includes(user.id));
|
||||
const allSelected = users.length > 0 && users.every((user) => selectedUsers.includes(user.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -195,28 +194,18 @@ export function UserListTable({
|
||||
</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant={user.is_active ? 'default' : 'secondary'}
|
||||
>
|
||||
<Badge variant={user.is_active ? 'default' : 'secondary'}>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{user.is_superuser ? (
|
||||
<Check
|
||||
className="h-4 w-4 mx-auto text-green-600"
|
||||
aria-label="Yes"
|
||||
/>
|
||||
<Check className="h-4 w-4 mx-auto text-green-600" aria-label="Yes" />
|
||||
) : (
|
||||
<X
|
||||
className="h-4 w-4 mx-auto text-muted-foreground"
|
||||
aria-label="No"
|
||||
/>
|
||||
<X className="h-4 w-4 mx-auto text-muted-foreground" aria-label="No" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(user.created_at), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>{format(new Date(user.created_at), 'MMM d, yyyy')}</TableCell>
|
||||
<TableCell>
|
||||
<UserActionMenu
|
||||
user={user}
|
||||
@@ -237,11 +226,8 @@ export function UserListTable({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.page_size + 1} to{' '}
|
||||
{Math.min(
|
||||
pagination.page * pagination.page_size,
|
||||
pagination.total
|
||||
)}{' '}
|
||||
of {pagination.total} users
|
||||
{Math.min(pagination.page * pagination.page_size, pagination.total)} of{' '}
|
||||
{pagination.total} users
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -266,13 +252,9 @@ export function UserListTable({
|
||||
|
||||
return (
|
||||
<div key={page} className="flex items-center">
|
||||
{showEllipsis && (
|
||||
<span className="px-2 text-muted-foreground">...</span>
|
||||
)}
|
||||
{showEllipsis && <span className="px-2 text-muted-foreground">...</span>}
|
||||
<Button
|
||||
variant={
|
||||
page === pagination.page ? 'default' : 'outline'
|
||||
}
|
||||
variant={page === pagination.page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page)}
|
||||
className="w-9"
|
||||
|
||||
@@ -28,7 +28,8 @@ export function UserManagementContent() {
|
||||
|
||||
// Convert filter strings to booleans for API
|
||||
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
|
||||
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)
|
||||
const handleSelectAll = (selected: boolean) => {
|
||||
if (selected) {
|
||||
const selectableUsers = users
|
||||
.filter((u) => u.id !== currentUser?.id)
|
||||
.map((u) => u.id);
|
||||
const selectableUsers = users.filter((u) => u.id !== currentUser?.id).map((u) => u.id);
|
||||
setSelectedUsers(selectableUsers);
|
||||
} else {
|
||||
setSelectedUsers([]);
|
||||
@@ -141,9 +140,7 @@ export function UserManagementContent() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">All Users</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage user accounts and permissions
|
||||
</p>
|
||||
<p className="text-muted-foreground">Manage user accounts and permissions</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateUser}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -83,9 +83,8 @@ export function AuthGuard({ children, requireAdmin = false, fallback }: AuthGuar
|
||||
// If not loading and not authenticated, redirect to login
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
// Preserve intended destination
|
||||
const returnUrl = pathname !== config.routes.login
|
||||
? `?returnUrl=${encodeURIComponent(pathname)}`
|
||||
: '';
|
||||
const returnUrl =
|
||||
pathname !== config.routes.login ? `?returnUrl=${encodeURIComponent(pathname)}` : '';
|
||||
|
||||
router.push(`${config.routes.login}${returnUrl}`);
|
||||
}
|
||||
|
||||
@@ -21,9 +21,7 @@ export function AuthLayoutClient({ children }: AuthLayoutClientProps) {
|
||||
|
||||
{/* Auth card */}
|
||||
<div className="w-full max-w-md">
|
||||
<div className="rounded-lg border bg-card p-8 shadow-sm">
|
||||
{children}
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-8 shadow-sm">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,10 +24,7 @@ import config from '@/config/app.config';
|
||||
// ============================================================================
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, 'Email is required')
|
||||
.email('Please enter a valid email address'),
|
||||
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Password is required')
|
||||
@@ -187,11 +184,7 @@ export function LoginForm({
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Signing in...' : 'Sign in'}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -57,8 +57,7 @@ function calculatePasswordStrength(password: string): {
|
||||
const hasNumber = /[0-9]/.test(password);
|
||||
const hasUppercase = /[A-Z]/.test(password);
|
||||
|
||||
const strength =
|
||||
(hasMinLength ? 33 : 0) + (hasNumber ? 33 : 0) + (hasUppercase ? 34 : 0);
|
||||
const strength = (hasMinLength ? 33 : 0) + (hasNumber ? 33 : 0) + (hasUppercase ? 34 : 0);
|
||||
|
||||
return { hasMinLength, hasNumber, hasUppercase, strength };
|
||||
}
|
||||
@@ -208,9 +207,7 @@ export function PasswordResetConfirmForm({
|
||||
{...form.register('new_password')}
|
||||
aria-invalid={!!form.formState.errors.new_password}
|
||||
aria-describedby={
|
||||
form.formState.errors.new_password
|
||||
? 'new-password-error'
|
||||
: 'password-requirements'
|
||||
form.formState.errors.new_password ? 'new-password-error' : 'password-requirements'
|
||||
}
|
||||
aria-required="true"
|
||||
/>
|
||||
@@ -261,8 +258,7 @@ export function PasswordResetConfirmForm({
|
||||
: 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{passwordStrength.hasUppercase ? '✓' : '○'} Contains an uppercase
|
||||
letter
|
||||
{passwordStrength.hasUppercase ? '✓' : '○'} Contains an uppercase letter
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -283,9 +279,7 @@ export function PasswordResetConfirmForm({
|
||||
{...form.register('confirm_password')}
|
||||
aria-invalid={!!form.formState.errors.confirm_password}
|
||||
aria-describedby={
|
||||
form.formState.errors.confirm_password
|
||||
? 'confirm-password-error'
|
||||
: undefined
|
||||
form.formState.errors.confirm_password ? 'confirm-password-error' : undefined
|
||||
}
|
||||
aria-required="true"
|
||||
/>
|
||||
|
||||
@@ -23,10 +23,7 @@ import { getGeneralError, getFieldErrors, isAPIErrorArray } from '@/lib/api/erro
|
||||
// ============================================================================
|
||||
|
||||
const resetRequestSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, 'Email is required')
|
||||
.email('Please enter a valid email address'),
|
||||
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
|
||||
});
|
||||
|
||||
type ResetRequestFormData = z.infer<typeof resetRequestSchema>;
|
||||
@@ -169,11 +166,7 @@ export function PasswordResetRequestForm({
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Sending...' : 'Send Reset Instructions'}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -25,10 +25,7 @@ import config from '@/config/app.config';
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, 'Email is required')
|
||||
.email('Please enter a valid email address'),
|
||||
email: z.string().min(1, 'Email is required').email('Please enter a valid email address'),
|
||||
first_name: z
|
||||
.string()
|
||||
.min(1, 'First name is required')
|
||||
@@ -45,9 +42,7 @@ const registerSchema = z
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[0-9]/, 'Password must contain at least one number')
|
||||
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter'),
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(1, 'Please confirm your password'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
@@ -88,11 +83,7 @@ interface RegisterFormProps {
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function RegisterForm({
|
||||
onSuccess,
|
||||
showLoginLink = true,
|
||||
className,
|
||||
}: RegisterFormProps) {
|
||||
export function RegisterForm({ onSuccess, showLoginLink = true, className }: RegisterFormProps) {
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const registerMutation = useRegister();
|
||||
|
||||
@@ -242,7 +233,11 @@ export function RegisterForm({
|
||||
disabled={isSubmitting}
|
||||
{...form.register('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 && (
|
||||
<p id="password-error" className="text-sm text-destructive">
|
||||
@@ -253,13 +248,25 @@ export function RegisterForm({
|
||||
{/* Password Strength Indicator */}
|
||||
{password.length > 0 && !form.formState.errors.password && (
|
||||
<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
|
||||
</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
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
@@ -279,7 +286,9 @@ export function RegisterForm({
|
||||
disabled={isSubmitting}
|
||||
{...form.register('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 && (
|
||||
<p id="confirmPassword-error" className="text-sm text-destructive">
|
||||
@@ -289,11 +298,7 @@ export function RegisterForm({
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating account...' : 'Create account'}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -5,7 +5,16 @@
|
||||
|
||||
'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 { CHART_PALETTES } from '@/lib/chart-colors';
|
||||
|
||||
|
||||
@@ -5,7 +5,16 @@
|
||||
|
||||
'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 { format, subDays } from 'date-fns';
|
||||
import { CHART_PALETTES } from '@/lib/chart-colors';
|
||||
|
||||
@@ -5,7 +5,16 @@
|
||||
|
||||
'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 { format, subDays } from 'date-fns';
|
||||
import { CHART_PALETTES } from '@/lib/chart-colors';
|
||||
|
||||
@@ -61,19 +61,12 @@ export function BeforeAfter({
|
||||
{(title || description) && (
|
||||
<div className="space-y-2">
|
||||
{title && <h3 className="text-xl font-semibold">{title}</h3>}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comparison Grid */}
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-4',
|
||||
vertical ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-2'
|
||||
)}
|
||||
>
|
||||
<div className={cn('grid gap-4', vertical ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-2')}>
|
||||
{/* Before (Anti-pattern) */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader className="space-y-2 pb-4">
|
||||
@@ -94,9 +87,7 @@ export function BeforeAfter({
|
||||
</div>
|
||||
{/* Caption */}
|
||||
{before.caption && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{before.caption}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground italic">{before.caption}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -124,9 +115,7 @@ export function BeforeAfter({
|
||||
</div>
|
||||
{/* Caption */}
|
||||
{after.caption && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{after.caption}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground italic">{after.caption}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -61,12 +61,8 @@ export function CodeSnippet({
|
||||
{(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 gap-2">
|
||||
{title && (
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
)}
|
||||
{language && (
|
||||
<span className="text-xs text-muted-foreground">({language})</span>
|
||||
)}
|
||||
{title && <span className="text-sm font-medium text-foreground">{title}</span>}
|
||||
{language && <span className="text-xs text-muted-foreground">({language})</span>}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -139,8 +135,7 @@ export function CodeSnippet({
|
||||
key={idx}
|
||||
className={cn(
|
||||
'leading-6',
|
||||
highlightLines.includes(idx + 1) &&
|
||||
'bg-accent/20 -mx-4 px-4'
|
||||
highlightLines.includes(idx + 1) && 'bg-accent/20 -mx-4 px-4'
|
||||
)}
|
||||
>
|
||||
{line || ' '}
|
||||
|
||||
@@ -9,11 +9,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Mail, User,
|
||||
Settings, LogOut, Shield, AlertCircle, Info,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { Mail, User, Settings, LogOut, Shield, AlertCircle, Info, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
@@ -281,7 +277,11 @@ export function ComponentShowcase() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Accept terms and conditions
|
||||
</Label>
|
||||
@@ -357,11 +357,7 @@ export function ComponentShowcase() {
|
||||
</ExampleSection>
|
||||
|
||||
{/* Badges */}
|
||||
<ExampleSection
|
||||
id="badges"
|
||||
title="Badges"
|
||||
description="Status indicators and labels"
|
||||
>
|
||||
<ExampleSection id="badges" title="Badges" description="Status indicators and labels">
|
||||
<Example
|
||||
title="Badge Variants"
|
||||
code={`<Badge>Default</Badge>
|
||||
@@ -411,11 +407,7 @@ export function ComponentShowcase() {
|
||||
</ExampleSection>
|
||||
|
||||
{/* Alerts */}
|
||||
<ExampleSection
|
||||
id="alerts"
|
||||
title="Alerts"
|
||||
description="Contextual feedback messages"
|
||||
>
|
||||
<ExampleSection id="alerts" title="Alerts" description="Contextual feedback messages">
|
||||
<div className="space-y-4">
|
||||
<Example
|
||||
title="Alert Variants"
|
||||
@@ -439,17 +431,13 @@ export function ComponentShowcase() {
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Information</AlertTitle>
|
||||
<AlertDescription>
|
||||
This is an informational alert message.
|
||||
</AlertDescription>
|
||||
<AlertDescription>This is an informational alert message.</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
Something went wrong. Please try again.
|
||||
</AlertDescription>
|
||||
<AlertDescription>Something went wrong. Please try again.</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</Example>
|
||||
@@ -545,8 +533,8 @@ export function ComponentShowcase() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete your account
|
||||
and remove your data from our servers.
|
||||
This action cannot be undone. This will permanently delete your account and
|
||||
remove your data from our servers.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -594,9 +582,7 @@ export function ComponentShowcase() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="password" className="space-y-4 mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Change your password here.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Change your password here.</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current">Current Password</Label>
|
||||
<Input id="current" type="password" />
|
||||
@@ -607,11 +593,7 @@ export function ComponentShowcase() {
|
||||
</ExampleSection>
|
||||
|
||||
{/* Table */}
|
||||
<ExampleSection
|
||||
id="table"
|
||||
title="Table"
|
||||
description="Data tables with headers and cells"
|
||||
>
|
||||
<ExampleSection id="table" title="Table" description="Data tables with headers and cells">
|
||||
<Example
|
||||
title="Table Example"
|
||||
code={`<Table>
|
||||
|
||||
@@ -24,10 +24,7 @@ interface DevBreadcrumbsProps {
|
||||
|
||||
export function DevBreadcrumbs({ items, className }: DevBreadcrumbsProps) {
|
||||
return (
|
||||
<nav
|
||||
className={cn('bg-muted/30 border-b', className)}
|
||||
aria-label="Breadcrumb"
|
||||
>
|
||||
<nav className={cn('bg-muted/30 border-b', className)} aria-label="Breadcrumb">
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<ol className="flex items-center gap-2 text-sm">
|
||||
{/* Home link */}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user