Refactor useAuth hook, settings components, and docs for formatting and readability improvements

- Consolidated multi-line arguments into single lines where appropriate in `useAuth`.
- Improved spacing and readability in data processing across components (`ProfileSettingsForm`, `PasswordChangeForm`, `SessionCard`).
- Applied consistent table and markdown formatting in design system docs (e.g., `README.md`, `08-ai-guidelines.md`, `00-quick-start.md`).
- Updated code snippets to ensure adherence to Prettier rules and streamlined JSX structures.
This commit is contained in:
2025-11-10 11:03:45 +01:00
parent 464a6140c4
commit 96df7edf88
208 changed files with 4056 additions and 4556 deletions

View File

@@ -1,8 +1,6 @@
{
"all": true,
"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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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
```

View File

@@ -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 🎯

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -24,6 +24,7 @@
We use **[shadcn/ui](https://ui.shadcn.com)**, a collection of accessible, customizable components built on **Radix UI primitives**.
**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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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/)

View File

@@ -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

View File

@@ -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

View File

@@ -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/`

View File

@@ -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

View File

@@ -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

View File

@@ -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');
});

View File

@@ -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');
});

View File

@@ -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 }) => {

View File

@@ -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

View File

@@ -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 }) => {

View File

@@ -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

View File

@@ -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');

View File

@@ -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');

View File

@@ -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');

View File

@@ -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,

View File

@@ -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
}
/**

View File

@@ -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

View File

@@ -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);

View File

@@ -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

View File

@@ -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;

View File

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

View File

@@ -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');

View File

@@ -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;
});

View File

@@ -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>;
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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&apos;ll send you an email with instructions to reset your password
</p>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
),
}
);

View File

@@ -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">

View File

@@ -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>
);
}

View File

@@ -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=&quot;alert&quot;</code> to error messages</span>
<span>
Add <code className="text-xs">role=&quot;alert&quot;</code> to error messages
</span>
</li>
<li 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">

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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">

View File

@@ -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&apos;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">

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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

View File

@@ -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}

View File

@@ -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>

View File

@@ -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 */}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>

View File

@@ -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 () => {

View File

@@ -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>

View File

@@ -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"

View File

@@ -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" />

View File

@@ -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}`);
}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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"
/>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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>

View File

@@ -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 || ' '}

View File

@@ -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>

View File

@@ -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