Skip to main content

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

4. Sidebar + Content Layout

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

9. Button Variants

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.

10. Tooltip Usage

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

ElementClasses
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 texttext-sm text-slate-600 dark:text-slate-400
Muted/descriptiontext-muted-foreground
Icons: lucide-react only. Sizes: w-4 h-4 (buttons), w-5 h-5 (headers), w-8 h-8 (stat cards).