Documentation Index
Fetch the complete documentation index at: https://grantmaster.dev/llms.txt
Use this file to discover all available pages before exploring further.
Component Cookbook
Copy-paste recipes for common UI compositions using the GrantMaster design system. All examples use actual component APIs from the codebase.
1. PageShell Page (Preferred)
The recommended pattern for new pages. Declarative config + automatic RBAC filtering for tabs and actions.
Step 1 — Define the page config (pages.config.ts):
import { LayoutDashboard, List, Plus } from 'lucide-react';
import type { PageDef } from '@/routes/page.types';
import { Permission } from '@/shared/auth/contracts';
export const myFeaturePageDef: PageDef = {
id: 'my-feature',
path: '/my-feature',
title: 'My Feature',
description: 'Brief description',
icon: LayoutDashboard,
maxWidth: 'full',
tabs: [
{ value: 'overview', title: 'Overview', icon: LayoutDashboard },
{ value: 'details', title: 'Details', icon: List },
],
actions: [
{ id: 'create', label: 'New Item', icon: Plus, variant: 'default',
handlerKey: 'handleCreate', permissions: [Permission.MANAGE_PROJECTS] },
],
};
Step 2 — Use PageShell in the component:
import { useMemo, useState } from 'react';
import { myFeaturePageDef } from './pages.config';
import { usePageResolver } from '@/routes/usePageResolver';
import { PageShell } from '@/components/layout/PageShell';
export function MyFeaturePage() {
const [activeTab, setActiveTab] = useState('overview');
const handlers = useMemo(() => ({
handleCreate: () => { /* open modal or navigate */ },
}), []);
const config = usePageResolver(myFeaturePageDef, { handlers });
return (
<PageShell config={config} activeTab={activeTab} onTabChange={setActiveTab}>
{activeTab === 'overview' && <OverviewContent />}
{activeTab === 'details' && <DetailsContent />}
</PageShell>
);
}
Tabs and actions the user lacks permissions for are automatically filtered — no conditional rendering needed.
Reference: src/features/projects/, src/features/expenses/, src/features/journals/
2. Standard Page (Legacy)
Still valid for pages with custom layouts that don’t fit the PageShell pattern. For new standard pages, prefer Recipe 1 (PageShell).
import { PageLayout } from '@/components/ui/PageLayout';
import { PageHeader } from '@/components/ui/PageHeader';
export function MyPage() {
return (
<PageLayout>
<PageHeader
title="Page Title"
description="Brief description of this page"
/>
{/* page content */}
</PageLayout>
);
}
PageLayout accepts maxWidth (sm | md | lg | xl | 2xl | full) and padding props.
3. Tabbed Page (Legacy)
For new tabbed pages, prefer Recipe 1 (PageShell) which handles tabs declaratively. This manual pattern is still valid for complex custom layouts.
import { useState } from 'react';
import { PageLayout } from '@/components/ui/PageLayout';
import { PageHeader } from '@/components/ui/PageHeader';
import { PageTabs } from '@/components/ui/PageTabs';
const tabs = [
{ id: 'overview', label: 'Overview' },
{ id: 'details', label: 'Details' },
{ id: 'history', label: 'History' },
];
export function MyTabbedPage() {
const [activeTab, setActiveTab] = useState('overview');
return (
<PageLayout>
<PageHeader title="My Feature" />
<PageTabs tabs={tabs} activeTab={activeTab} onTabChange={setActiveTab} />
{activeTab === 'overview' && <OverviewContent />}
{activeTab === 'details' && <DetailsContent />}
{activeTab === 'history' && <HistoryContent />}
</PageLayout>
);
}
Important: Render tab content externally — never pass content via the tab’s content prop.
Reference: src/features/billing/components/Billing.tsx
import { useState } from 'react';
import { Settings, Wrench, Shield } from 'lucide-react';
import { SecondarySidebar } from '@/components/ui/SecondarySidebar';
const sidebarItems = [
{ id: 'general', label: 'General', icon: Settings },
{ id: 'advanced', label: 'Advanced', icon: Wrench },
{ id: 'security', label: 'Security', icon: Shield },
];
export function SettingsPage() {
const [activeItem, setActiveItem] = useState('general');
return (
<div className="flex gap-6">
<SecondarySidebar
items={sidebarItems}
activeItem={activeItem}
onItemChange={setActiveItem}
searchable={false}
/>
<div className="flex-1">
{activeItem === 'general' && <GeneralSettings />}
{activeItem === 'advanced' && <AdvancedSettings />}
{activeItem === 'security' && <SecuritySettings />}
</div>
</div>
);
}
Use searchable={true} when sidebar has 10+ items. For grouped items with collapsible sub-menus, use the groups prop instead of items.
Visual: rounded card, primary-600 active state (white text on blue bg), w-64 default width.
5. Form Page
import { useZodForm } from '@/hooks/useZodForm';
import { PageLayout } from '@/components/ui/PageLayout';
import { PageHeader } from '@/components/ui/PageHeader';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { mySchema } from '@/schemas/my.schema';
export function CreateItemPage() {
const { form, handleSubmit, isSubmitting, isDirty, FormFieldController } = useZodForm({
schema: mySchema,
defaultValues: { name: '', description: '', amount: 0 },
onSubmit: async (data) => {
await myService.create(data);
},
});
return (
<PageLayout maxWidth="lg">
<PageHeader title="Create Item" />
<form onSubmit={handleSubmit} className="space-y-6">
<FormFieldController
name="name"
label="Name"
render={({ field }) => <Input {...field} />}
/>
<FormFieldController
name="description"
label="Description"
render={({ field }) => <Textarea {...field} />}
/>
<FormFieldController
name="amount"
label="Amount"
render={({ field }) => <Input type="number" {...field} />}
/>
<div className="flex justify-end gap-3">
<Button variant="outline" type="button">Cancel</Button>
<Button type="submit" isLoading={isSubmitting} loadingText="Saving...">
Save
</Button>
</div>
</form>
</PageLayout>
);
}
Never use useValidatedForm (deprecated). Always use useZodForm.
6. Stat Cards Row
import { DollarSign, Users, TrendingUp, AlertTriangle } from 'lucide-react';
import { CardVariants } from '@/components/ui/CardVariants';
const stats = [
{ label: 'Total Budget', value: '$125,000', icon: DollarSign, trend: '+12%' },
{ label: 'Team Members', value: '24', icon: Users },
{ label: 'Completion', value: '78%', icon: TrendingUp, trend: '+5%' },
{ label: 'At Risk', value: '3', icon: AlertTriangle },
];
export function StatsRow() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat) => (
<div key={stat.label} className={CardVariants.stat}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-600 dark:text-slate-400">{stat.label}</p>
<p className="text-2xl font-bold text-slate-900 dark:text-white">{stat.value}</p>
</div>
<stat.icon className="w-8 h-8 text-primary-500" />
</div>
{stat.trend && (
<p className="text-xs text-emerald-600 mt-1">{stat.trend}</p>
)}
</div>
))}
</div>
);
}
CardVariants presets: default, compact, interactive, stat, widget, glass.
7. Status Badge Usage
import { StatusBadge, mapDomainStatus } from '@/components/ui/StatusBadge';
// Convert domain-specific strings to base StatusType
<StatusBadge status={mapDomainStatus('approved')} /> {/* → success */}
<StatusBadge status={mapDomainStatus('pending')} /> {/* → pending */}
<StatusBadge status={mapDomainStatus('rejected')} /> {/* → error */}
<StatusBadge status={mapDomainStatus('draft')} /> {/* → draft */}
6 base status types: success, warning, error, pending, draft, info.
Never create local badge/status components — always use StatusBadge with mapDomainStatus().
8. Modal Registration
Register in src/features/[feature]/modals.registry.ts:
import type { ModalDef } from '@/components/modals/modal.types';
import { Permission } from '@/shared/auth/contracts';
export const myModals: ModalDef[] = [
{
id: 'create-item',
component: () => import('./components/CreateItemModal'),
select: (mod) => mod.CreateItemModal ?? mod.default,
size: 'lg', // 'sm' | 'md' | 'lg' | 'xl' | 'full'
permissions: [Permission.CREATE_ITEMS],
variant: 'modal', // 'modal' | 'drawer' | 'sheet'
scope: 'feature', // 'feature' | 'global'
},
];
See: Modal System, Modals Inventory
import { Button } from '@/components/ui/button';
<Button variant="default">Primary Action</Button>
<Button variant="destructive">Delete</Button>
<Button variant="success">Approve</Button>
<Button variant="warning">Caution</Button>
<Button variant="outline">Secondary</Button>
<Button variant="ghost">Subtle</Button>
<Button variant="link">Link Style</Button>
{/* Loading state */}
<Button isLoading={true} loadingText="Saving...">Save</Button>
Never use raw <button> with inline classes — ESLint will block the build.
import { Tooltip } from '@/components/ui/Tooltip';
<Tooltip content="Helpful description" side="top">
<Button variant="ghost" size="icon">
<HelpCircle className="w-4 h-4" />
</Button>
</Tooltip>
Use the custom Tooltip with content and side props — not the shadcn multi-part pattern (TooltipProvider / TooltipTrigger / TooltipContent).
11. Typography Reference
| Element | Classes |
|---|
| Page title (h1) | text-3xl font-bold (via PageHeader) |
| Section heading (h2) | text-xl font-semibold text-slate-900 dark:text-white |
| Subsection (h3) | text-lg font-medium text-slate-800 dark:text-slate-200 |
| Body text | text-sm text-slate-600 dark:text-slate-400 |
| Muted/description | text-muted-foreground |
Icons: lucide-react only. Sizes: w-4 h-4 (buttons), w-5 h-5 (headers), w-8 h-8 (stat cards).