skills/ballee/ui-patterns/SKILL.md
UI patterns for Ballee using MakerKit @kit/ui components, React Server Components, dark mode support, and admin CRUD pages. Use when building UI components, forms, tables, or admin pages.
npx skillsauth add javeedishaq/ai-workflow-orchestrator skills/ballee/ui-patternsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
'use client' when needed// Buttons
import { Button } from '@kit/ui/button';
// Forms
import { Form, FormField, FormItem, FormLabel, FormControl } from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kit/ui/select';
// Layout
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@kit/ui/dialog';
// Data Display
import { DataTable } from '@kit/ui/enhanced-data-table';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@kit/ui/table';
import { Badge } from '@kit/ui/badge';
// Feedback
import { toast } from '@kit/ui/sonner';
The Table component from @kit/ui/table already includes a border wrapper. Do NOT wrap it with an additional <div className="rounded-md border">.
// ✅ CORRECT - Table has its own border
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map(item => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>{item.status}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
// ❌ WRONG - Creates double border
<div className="rounded-md border">
<Table>
...
</Table>
</div>
Why: The Table component wrapper div has rounded-lg border built in (see packages/ui/src/shadcn/table.tsx:11).
Server Component (default): Client Component ('use client'):
├─ Data fetching ├─ onClick, onChange handlers
├─ Database access ├─ useState, useEffect hooks
├─ Sensitive logic ├─ Browser APIs (localStorage)
└─ No interactivity └─ Real-time subscriptions
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Button } from '@kit/ui/button';
import { toast } from '@kit/ui/sonner';
const schema = z.object({
name: z.string().min(1, 'Required'),
email: z.string().email(),
});
export function MyForm({ onSubmit }) {
const form = useForm({
resolver: zodResolver(schema),
defaultValues: { name: '', email: '' },
});
const handleSubmit = async (data) => {
const result = await onSubmit(data);
if (result.success) {
toast.success('Saved');
form.reset();
} else {
toast.error(result.error.message);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving...' : 'Save'}
</Button>
</form>
</Form>
);
}
// page.tsx (Server Component)
import { AdminPageTemplate } from '@/components/admin/admin-page-template';
import { DataTable } from '@kit/ui/enhanced-data-table';
import { columns } from './columns';
import { loadItems } from './loader';
export default async function ItemsPage() {
const items = await loadItems();
return (
<AdminPageTemplate
title="Items"
description="Manage items"
createButton={{ label: 'Add Item', href: '/admin/items/new' }}
>
<DataTable columns={columns} data={items} />
</AdminPageTemplate>
);
}
Use semantic color tokens for general UI - they automatically switch in dark mode:
// ✅ DO - Semantic colors (no dark: variants needed)
className="bg-background text-foreground" // Base
className="bg-card text-card-foreground" // Cards
className="bg-muted text-muted-foreground" // Muted elements
className="bg-primary text-primary-foreground" // Primary actions
className="bg-secondary text-secondary-foreground" // Secondary
className="bg-destructive text-destructive-foreground" // Destructive
className="border-border" // Borders
className="border-input" // Input borders
// ❌ DON'T - Hardcoded colors (breaks dark mode)
className="bg-white text-black"
className="bg-slate-100 dark:bg-slate-800" // Use bg-muted instead
className="border-gray-200 dark:border-neutral-800" // Use border-border instead
For status indicators (success/warning/error/info), use hardcoded colors WITH explicit dark variants:
// ✅ DO - Status colors with dark mode variants
// Success (green)
className="bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-100 dark:border-green-700"
className="text-green-600 dark:text-green-400"
className="bg-green-50 dark:bg-green-950"
// Warning (amber/yellow)
className="bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-900/30 dark:text-amber-100 dark:border-amber-700"
className="text-amber-600 dark:text-amber-400"
// Error (red)
className="bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-100 dark:border-red-700"
className="text-red-600 dark:text-red-400"
// Info (blue)
className="bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/30 dark:text-blue-100 dark:border-blue-700"
className="text-blue-600 dark:text-blue-400"
const getColorClass = (percentage: number) => {
if (percentage === 100) return 'text-green-600 dark:text-green-400';
if (percentage >= 75) return 'text-emerald-600 dark:text-emerald-400';
if (percentage >= 50) return 'text-amber-600 dark:text-amber-400';
if (percentage > 0) return 'text-orange-600 dark:text-orange-400';
return 'text-red-600 dark:text-red-400';
};
| Element | Pattern |
|---------|---------|
| Cards | bg-card text-card-foreground border-border rounded-lg |
| Muted text | text-muted-foreground |
| Inputs | border-input placeholder:text-muted-foreground |
| Buttons | Use variants: default, outline, ghost, destructive |
| Status badges | Hardcoded colors with dark: variants |
| Icons with status | text-green-600 dark:text-green-400 |
Type-safe status mapping with Badge component:
import { Badge } from '@kit/ui/badge';
type Status = 'active' | 'pending' | 'completed' | 'canceled';
function getStatusVariant(status: Status): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'active': return 'default';
case 'pending': return 'secondary';
case 'completed': return 'outline';
case 'canceled': return 'destructive';
}
}
function getStatusLabel(status: Status): string {
const labels: Record<Status, string> = {
active: 'Active',
pending: 'Pending',
completed: 'Completed',
canceled: 'Canceled',
};
return labels[status];
}
export function StatusBadge({ status }: { status: Status }) {
return (
<Badge variant={getStatusVariant(status)}>
{getStatusLabel(status)}
</Badge>
);
}
import { EmptyState, EmptyStateHeading, EmptyStateText, EmptyStateButton } from '@kit/ui/empty-state';
import { FolderOpen } from 'lucide-react';
<EmptyState>
<FolderOpen className="h-12 w-12 text-muted-foreground" />
<EmptyStateHeading>No items found</EmptyStateHeading>
<EmptyStateText>Get started by creating your first item.</EmptyStateText>
<EmptyStateButton onClick={onCreate}>Create Item</EmptyStateButton>
</EmptyState>
Filters that sync with URL search params:
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { Input } from '@kit/ui/input';
import { useDebouncedCallback } from 'use-debounce';
export function TableSearchInput({ placeholder = 'Search...' }) {
const router = useRouter();
const searchParams = useSearchParams();
const handleSearch = useDebouncedCallback((term: string) => {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('search', term);
} else {
params.delete('search');
}
params.set('page', '1'); // Reset pagination
router.push(`?${params.toString()}`);
}, 300);
return (
<Input
placeholder={placeholder}
defaultValue={searchParams.get('search') ?? ''}
onChange={(e) => handleSearch(e.target.value)}
/>
);
}
Consistent icon + text button styling:
import { Button } from '@kit/ui/button';
import { Plus, Download, Trash2 } from 'lucide-react';
// Icon with text
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Item
</Button>
// Icon only (with aria-label for accessibility)
<Button variant="ghost" size="icon" aria-label="Delete item">
<Trash2 className="h-4 w-4" />
</Button>
// Common icon sizes: h-4 w-4 (default), h-5 w-5 (larger)
// Card grid - responsive columns
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map(item => <ItemCard key={item.id} item={item} />)}
</div>
// Form layout - two columns on desktop
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField name="firstName" ... />
<FormField name="lastName" ... />
</div>
// ✅ Icon-only buttons need aria-label
<Button variant="ghost" size="icon" aria-label="Close dialog">
<X className="h-4 w-4" />
</Button>
// ✅ Form inputs need labels
<FormField
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel> {/* Required */}
<FormControl>
<Input {...field} aria-describedby="email-hint" />
</FormControl>
<FormDescription id="email-hint">We'll never share your email.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
// ✅ Error states need aria-invalid
<Input aria-invalid={!!error} aria-describedby={error ? 'error-msg' : undefined} />
// ✅ Interactive elements need keyboard support
<div role="button" tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && onClick()}>
Use date-fns for consistent date formatting:
import { format, formatDistanceToNow } from 'date-fns';
// Display formats
format(date, 'LLL dd, y') // "Jan 15, 2025"
format(date, 'yyyy-MM-dd') // "2025-01-15"
format(date, 'HH:mm') // "14:30"
format(date, 'EEEE, MMMM d') // "Wednesday, January 15"
// Relative time
formatDistanceToNow(date, { addSuffix: true }) // "2 hours ago"
const columns = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'status', header: 'Status',
cell: ({ row }) => <Badge>{row.original.status}</Badge>
},
{ id: 'actions', cell: ({ row }) => <RowActions item={row.original} /> },
];
<DataTable
columns={columns}
data={items}
searchKey="name"
filterableColumns={[
{ id: 'status', title: 'Status', options: ['active', 'inactive'] },
]}
/>
All admin tables MUST follow this consistent architecture for actions columns.
| Primary Actions | Data Columns... | Destructive Actions |
|-----------------|------------------------|---------------------|
| [View] [Edit] | Name, Status, Date... | [Delete] |
Action Types by Position:
| Type | Position | Icon | Confirmation Required |
|------|----------|------|----------------------|
| View | First column | Eye | No |
| Edit | First column | Pencil | No |
| Download | First column | Download | No |
| Delete | Last column | Trash2 | Yes (AlertDialog) |
| Special Admin | Last column | Varies | Context-dependent |
import { Eye, Pencil, Download } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kit/ui/tooltip';
import { Trans } from '@kit/ui/trans';
import Link from 'next/link';
// Column definition
{
id: 'actions',
header: '',
cell: ({ row }) => {
const item = row.original;
return (
<TooltipProvider>
<div className="flex items-center gap-1">
{/* View - always present */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/items/${item.id}`}>
<Eye className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey="common:view" defaults="View" />
</TooltipContent>
</Tooltip>
{/* Edit - if applicable */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/items/${item.id}/edit`}>
<Pencil className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey="common:edit" defaults="Edit" />
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
},
enableSorting: false,
enableHiding: false,
}
CRITICAL: All delete actions MUST have AlertDialog confirmation.
import { Trash2 } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kit/ui/tooltip';
import { cn } from '@kit/ui/utils';
import { Trans } from '@kit/ui/trans';
// State in component
const [itemToDelete, setItemToDelete] = useState<Item | null>(null);
// Column definition (place as LAST column)
{
id: 'destructive_actions',
header: '',
cell: ({ row }) => {
const item = row.original;
const canDelete = /* your condition - e.g., no dependencies */;
return (
<TooltipProvider>
<div className="flex items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span> {/* span wrapper for disabled button tooltip */}
<Button
variant="ghost"
size="icon"
onClick={() => setItemToDelete(item)}
disabled={!canDelete}
>
<Trash2
className={cn(
'h-4 w-4',
canDelete ? 'text-destructive' : 'text-muted-foreground'
)}
aria-hidden="true"
/>
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{canDelete ? (
<Trans i18nKey="common:delete" defaults="Delete" />
) : (
<Trans i18nKey="common:cannotDelete" defaults="Cannot delete" />
)}
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
},
enableSorting: false,
enableHiding: false,
}
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@kit/ui/alert-dialog';
import { Trans } from '@kit/ui/trans';
// State
const [itemToDelete, setItemToDelete] = useState<Item | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Handler
const handleDelete = async () => {
if (!itemToDelete) return;
setIsDeleting(true);
try {
const result = await deleteItemAction({ id: itemToDelete.id });
if (result.success) {
toast.success(t('admin:items.deleted'));
setItemToDelete(null);
} else {
toast.error(result.error || t('admin:items.deleteError'));
}
} catch (error) {
toast.error(t('admin:items.deleteError'));
} finally {
setIsDeleting(false);
}
};
// Dialog (render outside the table)
<AlertDialog
open={!!itemToDelete}
onOpenChange={(open) => !open && setItemToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans
i18nKey="admin:items.deleteTitle"
values={{ name: itemToDelete?.name }}
/>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey="admin:items.deleteDescription" />
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
<Trans i18nKey="common:cancel" />
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<Trans i18nKey="common:deleting" />
) : (
<Trans i18nKey="common:delete" />
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
'use client';
import { useState, useMemo } from 'react';
import { ColumnDef } from '@tanstack/react-table';
// ... other imports
export function ItemsTable({ items, pageCount, pageSize, page, filters }) {
const { t } = useTranslation();
const [itemToDelete, setItemToDelete] = useState<Item | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => { /* ... */ };
const columns: ColumnDef<Item>[] = useMemo(() => [
// 1. PRIMARY ACTIONS (first)
{
id: 'actions',
header: '',
cell: ({ row }) => { /* View, Edit, Download buttons */ },
enableSorting: false,
enableHiding: false,
},
// 2. DATA COLUMNS (middle)
{ id: 'name', header: t('admin:items.table.name'), /* ... */ },
{ id: 'status', header: t('admin:items.table.status'), /* ... */ },
{ id: 'created', header: t('admin:items.table.created'), /* ... */ },
// 3. DESTRUCTIVE ACTIONS (last) - if applicable
{
id: 'destructive_actions',
header: '',
cell: ({ row }) => { /* Delete button */ },
enableSorting: false,
enableHiding: false,
},
], [t, handleDelete]);
return (
<>
<DataTable columns={columns} data={items} /* ... */ />
{/* Delete confirmation dialog */}
<AlertDialog open={!!itemToDelete} /* ... */}>
{/* ... */}
</AlertDialog>
</>
);
}
Icon buttons with size="icon" provide:
gap-1No special mobile handling needed - the same icon buttons work on all screen sizes.
CRITICAL: Always wrap tooltip groups with <TooltipProvider>:
// ✅ CORRECT - Provider wraps all tooltips
<TooltipProvider>
<div className="flex items-center gap-1">
<Tooltip>...</Tooltip>
<Tooltip>...</Tooltip>
</div>
</TooltipProvider>
// ❌ WRONG - Missing provider causes runtime error
<div className="flex items-center gap-1">
<Tooltip>...</Tooltip> // Error: Tooltip must be used within TooltipProvider
</div>
The ESLint rule react-providers/require-provider enforces this at lint time.
'use client';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@kit/ui/dialog';
import { Button } from '@kit/ui/button';
import { useState } from 'react';
export function CreateDialog({ onSubmit }) {
const [open, setOpen] = useState(false);
const handleSubmit = async (data) => {
const result = await onSubmit(data);
if (result.success) {
setOpen(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Create</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Item</DialogTitle>
</DialogHeader>
<MyForm onSubmit={handleSubmit} />
</DialogContent>
</Dialog>
);
}
// Button loading
<Button disabled={isLoading}>
{isLoading ? 'Loading...' : 'Submit'}
</Button>
// Skeleton loading
import { Skeleton } from '@kit/ui/skeleton';
<Skeleton className="h-4 w-[200px]" />
sm:, md:, lg:sm: 640px - Small devices (large phones, small tablets)
md: 768px - Medium devices (tablets)
lg: 1024px - Large devices (laptops)
xl: 1280px - Extra large (desktops)
2xl: 1536px - Ultra wide screens
CRITICAL: Always use mx-auto with max-w-* to center content on wide screens.
| Page Type | Pattern | Use Case |
|-----------|---------|----------|
| Wizards/Forms | mx-auto max-w-2xl | Multi-step forms, settings |
| Single Forms | mx-auto max-w-3xl | Create/edit pages |
| Profile/Detail | mx-auto max-w-4xl | Profile pages, detail views |
| Dashboard | mx-auto max-w-6xl or full | Data-heavy pages, tables |
| Full Width | No max-width | Admin tables, kanban boards |
// ✅ CORRECT - Centered with max-width
<PageBody>
<div className="mx-auto max-w-2xl">
<WizardForm />
</div>
</PageBody>
// ❌ WRONG - Left-aligned, looks weird on desktop
<PageBody>
<div className="max-w-2xl">
<WizardForm />
</div>
</PageBody>
// page.tsx
import { PageBody, PageHeader } from '@kit/ui/page';
export default function MyPage() {
return (
<>
<PageHeader
title="Page Title"
description={<AppBreadcrumbs />}
/>
<PageBody>
{/* Choose appropriate max-width for content type */}
<div className="mx-auto max-w-4xl space-y-6">
{/* Page content */}
</div>
</PageBody>
</>
);
}
Mobile-First Visibility:
// Show on mobile, hide on desktop
<div className="sm:hidden">Mobile navigation</div>
// Hide on mobile, show on desktop
<div className="hidden sm:block">Desktop navigation</div>
// Different layouts at breakpoints
<div className="flex flex-col sm:flex-row">
<Sidebar className="w-full sm:w-64" />
<Content className="flex-1" />
</div>
Responsive Grid:
// 1 col mobile → 2 cols tablet → 3 cols desktop
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map(item => <Card key={item.id} />)}
</div>
// Two-column form on desktop
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField name="firstName" />
<FormField name="lastName" />
</div>
Responsive Spacing:
// Tighter spacing on mobile
<div className="space-y-4 sm:space-y-6 lg:space-y-8">
// Responsive padding
<div className="p-4 sm:p-6 lg:p-8">
// Responsive text sizes
<h1 className="text-xl sm:text-2xl lg:text-3xl">
// Full screen on mobile, constrained on desktop
<DialogContent className="flex max-h-[90vh] flex-col overflow-hidden sm:max-w-lg">
// Wider dialog for complex content
<DialogContent className="flex max-h-[90vh] flex-col overflow-hidden sm:max-w-2xl">
Use the unified MobileWizardLayout components for all wizard/stepper flows:
import { MobileWizardLayout } from '@kit/ui/mobile-wizard-layout';
import { WizardNavigation } from '@kit/ui/wizard-navigation';
import { WizardProgress } from '@kit/ui/wizard-progress';
MobileWizardLayout - Main wrapper with three slots:
<MobileWizardLayout
contentWidth="lg" // 'md' | 'lg' | 'xl' | 'full'
header={<WizardProgress ... />}
navigation={<WizardNavigation ... />}
>
{stepContent}
</MobileWizardLayout>
Features:
sm: breakpoint)pb-24 on mobile, sm:pb-8 on desktop)WizardProgress - Step indicator with variants:
// Bar variant (default) - progress bar with step counter
<WizardProgress
currentStep={0} // 0-based index
totalSteps={5}
stepTitle="Overview" // Optional
variant="bar" // 'bar' (default) | 'dots' | 'icons'
/>
// Icons variant - circles with icons and connector lines
import { Building, FileText, CreditCard } from 'lucide-react';
<WizardProgress
variant="icons"
currentStep={1}
steps={[
{ id: 'address', label: 'Home Address', icon: Building },
{ id: 'tax', label: 'Tax Info', icon: FileText },
{ id: 'bank', label: 'Bank Details', icon: CreditCard },
]}
showLabels={true} // Optional, defaults to true (hidden on mobile)
/>
common:stepOf key (bar variant)WizardNavigation - Bottom navigation buttons:
<WizardNavigation
isFirstStep={currentStep === 0}
isLastStep={isLastStep}
onBack={handleBack}
onNext={handleNext}
onSkip={handleSkip} // Optional - renders skip button
nextDisabled={!canProceed}
isPending={isLoading}
submitLabel={<Trans i18nKey="..." />} // Optional custom label
/>
Features:
Complete Example:
'use client';
import { MobileWizardLayout } from '@kit/ui/mobile-wizard-layout';
import { WizardNavigation } from '@kit/ui/wizard-navigation';
import { WizardProgress } from '@kit/ui/wizard-progress';
function MyWizard() {
const [currentStep, setCurrentStep] = useState(0);
const steps = ['Overview', 'Details', 'Review'];
const isLastStep = currentStep === steps.length - 1;
return (
<MobileWizardLayout
contentWidth="lg"
header={
<WizardProgress
currentStep={currentStep}
totalSteps={steps.length}
stepTitle={steps[currentStep]}
/>
}
navigation={
<WizardNavigation
isFirstStep={currentStep === 0}
isLastStep={isLastStep}
onBack={() => setCurrentStep((s) => s - 1)}
onNext={isLastStep ? handleSubmit : () => setCurrentStep((s) => s + 1)}
nextDisabled={!canProceed}
isPending={isSubmitting}
/>
}
>
<div className="space-y-4">
{renderStep()}
</div>
</MobileWizardLayout>
);
}
Usage in Ballee:
/home/(user)/feedback/[eventId])/home/(user)/dancer/contract-details)/home/(user)/dancer/profile/setup) - uses WizardProgress for mobile| Purpose | Mobile | Tablet (sm:) | Desktop (lg:) |
|---------|--------|----------------|-----------------|
| Hide/Show | hidden | sm:block | lg:hidden |
| Flex direction | flex-col | sm:flex-row | - |
| Grid columns | grid-cols-1 | sm:grid-cols-2 | lg:grid-cols-3 |
| Text size | text-sm | sm:text-base | lg:text-lg |
| Padding | p-4 | sm:p-6 | lg:p-8 |
| Gap | gap-2 | sm:gap-4 | lg:gap-6 |
These patterns are already established in the codebase:
// Wizard pages (contract-details, legal-status)
<div className="mx-auto max-w-2xl">
// Profile/dashboard pages
<div className="mx-auto w-full max-w-4xl space-y-6">
// Form pages
<div className="mx-auto max-w-3xl">
// Full-width with responsive padding
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
import { toast } from '@kit/ui/sonner';
// Success
toast.success('Item created');
// Error
toast.error('Failed to create item');
// With description
toast.success('Item created', { description: 'Redirecting...' });
Use the generic useCrudMutations factory for consistent CRUD operations. Located at apps/web/lib/hooks/use-crud-mutations.ts.
import { useCrudMutations } from '@/lib/hooks/use-crud-mutations';
import { createAction, updateAction, deleteAction } from './server/actions';
function useMyEntityMutations(options?: { onSuccess?: () => void }) {
const mutations = useCrudMutations<Entity, CreateInput, UpdateInput>({
entityName: 'entity', // For toast messages
queryKeys: ['entities', 'admin-entities'], // Cache keys to invalidate
actions: {
create: createAction,
update: updateAction,
delete: deleteAction,
},
optimisticUpdates: true, // Optional: enable optimistic UI
normalizeData: (data) => ({ // Optional: transform data before sending
...data,
isActive: data.isActive ?? true,
}),
}, {
onSuccess: (data) => options?.onSuccess?.(data),
});
// Return with entity-specific naming for backwards compatibility
return {
createEntity: mutations.create,
createEntityAsync: mutations.createAsync,
isCreating: mutations.isCreating,
updateEntity: mutations.update,
updateEntityAsync: mutations.updateAsync,
isUpdating: mutations.isUpdating,
deleteEntity: mutations.delete,
deleteEntityAsync: mutations.deleteAsync,
isDeleting: mutations.isDeleting,
};
}
The update function uses { id, data } format:
// Using the hook
const { updateEntity } = useEntityMutations();
// ✅ CORRECT - wrap data in 'data' property
await updateEntity({ id: entity.id, data: { name: 'New Name' } });
// ❌ WRONG - flat object (old pattern)
await updateEntity({ id: entity.id, name: 'New Name' });
These admin hooks use useCrudMutations:
use-venue-mutations.tsuse-choreographer-mutations.tsuse-piece-mutations.tsuse-client-mutations.tsuse-jurisdiction-mutations.tsuse-engagement-model-mutations.tsuse-per-diem-rate-mutations.tsuse-legal-status-type-mutations.tsDon't use useCrudMutations for specialized workflows:
Use useDialogState for consistent dialog state management. Located at apps/web/lib/hooks/use-dialog-state.ts.
import { useDialogState } from '@/lib/hooks/use-dialog-state';
function MyComponent() {
const dialog = useDialogState();
return (
<>
<Button onClick={dialog.handleOpen}>Open</Button>
<Dialog open={dialog.open} onOpenChange={dialog.setOpen}>
<DialogContent>
<Button onClick={dialog.handleClose}>Close</Button>
</DialogContent>
</Dialog>
</>
);
}
import { useDialogStateWithItem } from '@/lib/hooks/use-dialog-state';
function EditDialog() {
const { open, selectedItem, openWith, closeAndClear } = useDialogStateWithItem<Entity>();
return (
<>
<Button onClick={() => openWith(entity)}>Edit</Button>
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && closeAndClear()}>
<DialogContent>
{selectedItem && <EditForm entity={selectedItem} onClose={closeAndClear} />}
</DialogContent>
</Dialog>
</>
);
}
// useDialogState()
interface DialogState {
open: boolean;
setOpen: (open: boolean) => void;
handleOpen: () => void;
handleClose: () => void;
toggle: () => void;
}
// useDialogStateWithItem<T>() - extends DialogState
interface DialogStateWithItem<T> extends DialogState {
selectedItem: T | null;
openWith: (item: T) => void; // Open dialog with specific item
closeAndClear: () => void; // Close and clear selection (with delay)
}
Use formatters from apps/web/lib/formatters.ts for consistent formatting across the app.
import {
formatCurrency,
formatNumber,
formatPercent,
formatDate,
formatTime,
formatTimeRange,
formatShowtimeDate,
formatFeverDate,
getFullName,
formatReimbursementType,
formatReimbursementsText,
} from '@/lib/formatters';
// Currency (EUR default)
formatCurrency(1234.56); // "€1,234.56"
formatCurrency(1234.56, 'USD'); // "$1,234.56"
// Numbers
formatNumber(1234.567); // "1,234.57"
formatNumber(1234.567, 0); // "1,235"
formatPercent(0.856); // "85.6%"
formatPercent(0.856, 0); // "86%"
// Dates
formatDate('2025-01-15'); // "Jan 15, 2025"
formatDate('2025-01-15', 'full'); // "Wednesday, January 15, 2025"
formatDate('2025-01-15', 'short'); // "1/15/25"
formatTime('14:30:00'); // "2:30 PM"
formatTimeRange('09:00', '17:00'); // "9:00 AM - 5:00 PM"
formatShowtimeDate('2025-01-15'); // "Wed Jan 15"
formatFeverDate('2025-01-15'); // "15.01.2025"
// Names
getFullName({ first_name: 'John', last_name: 'Doe' }); // "John Doe"
// Reimbursements
formatReimbursementType('transport'); // "Transport"
formatReimbursementsText([{ type: 'hotel', amount: 100 }]); // "Hotel: €100.00"
Ballee uses a hybrid approach:
The key insight: Server Actions MUST call revalidatePath() to sync RSC data. Client-side queryClient.invalidateQueries() only affects client cache.
┌─────────────────────────────────────────────────────────────────┐
│ Client Component │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ useMutation hook │ │
│ │ - onMutate: optimistic update (instant UI) │ │
│ │ - mutationFn: calls server action │ │
│ │ - onSuccess: toast + optional client cache invalidation │ │
│ │ - onError: rollback + toast │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Server Action │
│ - Validate input (Zod) │
│ - Call service │
│ - revalidatePath() on success ← CRITICAL! │
│ - Return result │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ RSC Cache Invalidated → Page re-renders with fresh data │
└─────────────────────────────────────────────────────────────────┘
Step 1: Server Action with revalidatePath
// apps/web/app/admin/items/_lib/server/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { withAuthParams } from '@/lib/auth-wrappers';
export const deleteItemAction = withAuthParams(async (params, id: string) => {
const service = new ItemService(params.client);
const result = await service.delete(id);
if (result.success) {
revalidatePath('/admin/items'); // ← CRITICAL: Invalidates RSC cache
}
return result;
});
Step 2: TanStack Query Hook for Optimistic UI
// apps/web/app/admin/items/_lib/hooks/use-item-mutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from '@kit/ui/sonner';
import { deleteItemAction } from '../server/actions';
export function useItemMutations() {
const queryClient = useQueryClient();
// NOTE: No useRouter needed - server action handles revalidation
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
const result = await deleteItemAction(id);
if ('error' in result && result.error) {
throw new Error(result.error);
}
return result;
},
onMutate: async (id) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['items'] });
// Snapshot for rollback
const previousItems = queryClient.getQueryData(['items']);
// Optimistic update - UI updates instantly
queryClient.setQueryData(['items'], (old: Item[] | undefined) =>
old?.filter((item) => item.id !== id)
);
return { previousItems };
},
onError: (error, _id, context) => {
// Rollback on error
if (context?.previousItems) {
queryClient.setQueryData(['items'], context.previousItems);
}
toast.error(error instanceof Error ? error.message : 'Failed to delete');
},
onSuccess: () => {
// Optional: Invalidate client cache for components using useQuery
queryClient.invalidateQueries({ queryKey: ['items'] });
toast.success('Item deleted');
// NO router.refresh() - server action already called revalidatePath()!
},
});
return {
deleteItem: deleteMutation.mutate,
isDeleting: deleteMutation.isPending,
};
}
// ❌ WRONG - router.refresh() in client hook
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['items'] });
router.refresh(); // 700ms+ full page reload, move revalidation to server action
toast.success('Deleted');
};
// ❌ WRONG - Server action without revalidatePath
export const deleteItemAction = withAuthParams(async (params, id) => {
const result = await service.delete(id);
return result; // Missing revalidatePath!
});
// ❌ WRONG - Relying only on client cache invalidation
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['items'] });
// This only affects components using useQuery, not Server Components!
};
Only use router.refresh() in these rare cases:
// ✅ OK - Auth state change
const handleLogout = async () => {
await signOut();
router.refresh(); // Required to clear auth context
};
// ✅ OK - Account switch
const handleAccountSwitch = async (accountId: string) => {
await switchAccountAction(accountId);
router.refresh(); // Required to refresh workspace context
};
| Pattern | Perceived Latency | Full Page Reload |
|---------|------------------|------------------|
| router.refresh() | ~2000ms | Yes |
| revalidatePath() in action | ~200ms | No (targeted) |
| Optimistic + revalidatePath | ~20ms | No |
When refactoring existing code:
revalidatePath() call on successrouter.refresh() from onSuccessqueryClient.invalidateQueries() for client cacheCRITICAL: Never use BOTH a dialog AND a page for editing the same entity. Choose ONE.
| Criteria | Use Dialog | Use Page | |----------|------------|----------| | Field count | ≤6 fields | >6 fields | | Entity complexity | Simple lookup/config | Complex business entity | | Relationships | None or simple FK | Nested relationships, tabs | | Context needed | Minimal | Requires stats, history, related data | | Navigation flow | Stay in list view | Deep-link/bookmark important | | Example entities | Jurisdictions, Rates, Roles | Clients, Venues, Productions, Dancers |
✅ Good Dialog Use Cases:
- Engagement Models (code, name, description, vat_treatment, 2 checkboxes)
- Jurisdictions (code, name, currency, vat_rate, 2 checkboxes)
- Per Diem Rates (jurisdiction, role, amount, dates)
- Legal Status Types (name, description, status)
- Client User roles (role, is_primary_contact - 2 fields)
✅ Good Edit Page Use Cases:
- Clients (8+ fields, multiple tabs: info, users, fees)
- Productions (11+ fields, tabs: overview, roles, show sheet)
- Venues (20+ fields, tabs: details, documents, contacts)
- Dancers (many fields, tabs: profile, rates, documents)
- Events (many fields, tabs: details, cast, participations)
// ❌ NEVER: Both dialog AND page for same entity
// - edit-client-dialog.tsx + /admin/clients/[id] page
// - edit-production-dialog.tsx + /admin/productions/[id] page
// ❌ NEVER: Dialog for complex entities
// - Client edit dialog (8+ fields, needs tabs)
// - Event edit dialog (needs cast management)
// ❌ NEVER: Page for simple lookup tables
// - /admin/jurisdictions/[id] (only 6 fields)
// - /admin/engagement-models/[id] (only 6 fields)
Dialog Pattern (Simple Entity):
// Table with edit action opening dialog
function LookupTable({ items }) {
const [editItem, setEditItem] = useState(null);
return (
<>
<DataTable
columns={columns}
data={items}
onEditClick={(item) => setEditItem(item)}
/>
<EditDialog
item={editItem}
open={!!editItem}
onOpenChange={(open) => !open && setEditItem(null)}
/>
</>
);
}
Page Pattern (Complex Entity):
// Table row links to edit page
{
id: 'actions',
cell: ({ row }) => (
<Button variant="ghost" size="icon" asChild>
<Link href={`/admin/entities/${row.original.id}`}>
<Pencil className="h-4 w-4" />
</Link>
</Button>
),
}
// Edit page has inline form (not separate /edit route)
// apps/web/app/admin/entities/[id]/page.tsx
export default function EntityDetailPage({ params }) {
return (
<EntityTabs entity={entity}>
<TabContent value="overview">
<EntityForm entity={entity} /> {/* Inline edit form */}
</TabContent>
<TabContent value="related">
<RelatedItemsTable />
</TabContent>
</EntityTabs>
);
}
| Entity | Pattern | Status | |--------|---------|--------| | Clients | Page only | ✅ Correct | | Client Users | Dialog only | ✅ Correct (2 fields) | | Venues | Page only | ✅ Correct | | Productions | Page only | ✅ Correct | | Dancers | Page only | ✅ Correct | | Events | Page (needs edit form) | ⚠️ Missing edit functionality | | Engagement Models | Dialog only | ✅ Correct | | Jurisdictions | Dialog only | ✅ Correct | | Per Diem Rates | Dialog only | ✅ Correct | | Legal Status Types | Dialog only | ✅ Correct |
When removing a dialog in favor of an edit page:
For document management UI, use @kit/documents/components instead of building custom:
import {
// Display
DocumentList, // Read-only document gallery
DocumentCard, // Card view with thumbnail
DocumentRow, // List view row
// Viewer
DocumentViewerDialog, // Full-screen viewer with keyboard shortcuts
// Editable (CRUD)
SortableDocumentList, // Full CRUD with upload, edit, delete, reorder
} from '@kit/documents/components';
import { useDocumentViewer } from '@kit/documents/hooks';
import { DocumentList, DocumentViewerDialog } from '@kit/documents/components';
import { useDocumentViewer } from '@kit/documents/hooks';
function Gallery({ documents }) {
const viewer = useDocumentViewer(documents);
return (
<>
<DocumentList
documents={documents}
onView={(doc) => viewer.open(doc)}
/>
<DocumentViewerDialog
documents={documents}
currentIndex={viewer.currentIndex}
isOpen={viewer.isOpen}
onClose={viewer.close}
onNavigate={viewer.navigate}
/>
</>
);
}
For full patterns including CRUD, adapters, and PDF generation, see the document-patterns skill.
tools
# Test Patterns Testing patterns for reliable, maintainable, and fast tests. > **Template Usage:** Customize for your test framework (Vitest, Jest, Playwright, etc.) and assertion library. ## Test Structure ```typescript // user.test.ts import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { userService } from '@/services/user.service'; import { createTestUser, cleanupTestData } from '@/tests/helpers'; describe('UserService', () => { let testUserId: string; befor
tools
# State Management Patterns Client-side state management patterns for modern applications. > **Template Usage:** Customize for your state library (React Query, Zustand, Jotai, Redux, etc.). ## State Categories | Type | Description | Solution | |------|-------------|----------| | **Server State** | Data from API/database | React Query, SWR | | **Client State** | UI state, user preferences | Zustand, Jotai, useState | | **Form State** | Form inputs, validation | React Hook Form, Formik | | **U
development
# Service Patterns Service layer patterns for clean architecture with proper error handling, logging, and type safety. > **Template Usage:** Customize for your ORM (Prisma, Drizzle, TypeORM, etc.) and logging solution. ## Result Type Pattern Never throw exceptions from services. Always return a Result type. ```typescript // lib/result.ts export type Result<T, E = Error> = | { success: true; data: T } | { success: false; error: E }; export function ok<T>(data: T): Result<T, never> { r
testing
# Row-Level Security Patterns Database security patterns for multi-tenant and user-scoped data. > **Template Usage:** Customize for your database (PostgreSQL, Supabase, etc.) and auth system. ## RLS Fundamentals ### Enable RLS on Tables ```sql -- Enable RLS (required before policies take effect) ALTER TABLE users ENABLE ROW LEVEL SECURITY; ALTER TABLE posts ENABLE ROW LEVEL SECURITY; ALTER TABLE comments ENABLE ROW LEVEL SECURITY; -- Force RLS for table owners too (recommended) ALTER TABLE