SKILL.md10.6 KBView on GitHub
---
name: frontend-design
description: Cedar Mail frontend design system. Invoke when building UI components, hooks, feature modules, or any React code in apps/mail. Covers module structure, component patterns, styling, state management, and tRPC usage.
---

Cedar Mail frontend is React Router 7 + Jotai/Zustand + TanStack Query + Tailwind v4 + Shadcn UI.

---

## Module & File Organization

Code is organized by **feature module**, not by type. When adding something new, ask "which feature does this belong to?" and put it there — not in a top-level `components/` or `hooks/` folder. This keeps related code co-located and makes features self-contained: you can understand, modify, or delete a feature by looking in one place. Only promote code to shared locations when it's genuinely used across multiple modules.

Feature code lives in `apps/mail/modules/<feature>/`. Each module follows this internal structure:

```
modules/<feature>/
├── components/       # React components for this feature
├── hooks/            # Feature-scoped hooks
├── store/ or slice/  # Zustand slice (if feature has global state)
├── types.ts          # TypeScript types
├── utils/            # Pure helpers
├── constants.ts      # Magic values
└── index.ts          # Barrel exports
```

Shared/cross-cutting code goes in:

- `components/ui/` — Shadcn primitives (do not modify these)
- `components/` — shared non-UI components (providers, context, admin)
- `hooks/` — shared hooks used across multiple modules
- `lib/` — utilities and helpers

**Naming:**

- Components: `PascalCase.tsx`
- Hooks: `use-kebab-case.ts` or `useCamelCase.ts`
- Utilities: `kebab-case.ts`
- Types files: `types.ts` or `feature-types.ts`
- Store slices: `featureSlice.ts`

---

## Component Patterns

All components are functional. No class components.

**Standard component shape:**

```tsx
export function MyComponent({
	title,
	items = [],
	onSelect,
	className,
}: {
	title: string;
	items?: Item[];
	onSelect: (id: string) => void;
	className?: string;
}) {
	// ...
}
```

- Props: inline type in the destructure signature, not a separate interface
- Default values inline in destructure (e.g., `items = []`)
- Always accept `className` prop on leaf components for composability
- Export named (not default) unless it's a route page

**Compound components** — use Shadcn's pattern for grouped UI:

```tsx
<Card>
	<CardHeader>
		<CardTitle>Title</CardTitle>
	</CardHeader>
	<CardContent>...</CardContent>
</Card>
```

---

## Design Tokens

These are the patterns extracted from the actual codebase — use them consistently rather than reaching for arbitrary values.

**Spacing**

| Use case | Classes |
|---|---|
| Card / panel body | `p-6` |
| Compact section / header | `p-3` |
| Button / input padding | `px-3 py-2` |
| Icon button | `p-2` |
| Inline elements (icon + text) | `gap-1.5` or `gap-2` |
| Stacked items in a list | `space-y-1.5` or `space-y-2` |
| Between sections | `gap-4` |

Stick to the Tailwind `0.5` step scale (`1`, `1.5`, `2`, `3`, `4`, `6`, `8`). Avoid one-off values like `gap-5` or `p-7`.

**Border Radius**

| Surface | Class |
|---|---|
| Cards, dialogs, modals | `rounded-lg` |
| Buttons, inputs, textareas | `rounded-md` |
| Badges, chips, tags | `rounded-sm` |
| Avatars | `rounded-full` |

**Typography**

| Use case | Classes |
|---|---|
| Page / card title | `text-2xl font-semibold tracking-tight` |
| Dialog / section title | `text-lg font-semibold leading-none` |
| Labels, form fields | `text-sm font-medium` |
| Body / primary content | `text-sm` |
| Metadata, timestamps, captions | `text-xs text-muted-foreground` |
| Badges | `text-xs font-semibold` |
| Micro labels (use sparingly) | `text-xxs` |

Color hierarchy: `text-foreground` for primary, `text-muted-foreground` for secondary/supporting. Avoid hardcoded hex colors — use semantic tokens.

**Component Sizing**

| Element | Default | Small |
|---|---|---|
| Input height | `h-10` | `h-8` |
| Button height | `h-9` | `h-8` |
| Button (large) | `h-10` | — |
| Icon | `size-4` | `size-3` |
| Avatar | `size-10` | `size-6` |

**Layout**

The dominant pattern is `flex items-center justify-between gap-2` for rows and `flex flex-col gap-4` for vertical stacking. Common patterns:

```tsx
{/* Row: label on left, action on right */}
<div className="flex items-center justify-between gap-2">

{/* Row: icon + label */}
<div className="flex items-center gap-1.5">

{/* Vertical stack of sections */}
<div className="flex flex-col gap-4">

{/* List of items */}
<div className="flex flex-col space-y-1.5">

{/* Two-column with divider */}
<div className="flex gap-4">
  <div className="flex-1">...</div>
  <div className="w-1/2 border-l pl-4">...</div>
</div>
```

Grid is used sparingly — prefer flex unless you need true 2D alignment.

---

## Styling

**Always use `cn()` to compose class names:**

```tsx
import { cn } from '@/lib/utils';

className={cn(
  'base classes here',
  condition && 'conditional classes',
  className,  // always spread className last for overrides
)}
```

**Use CVA for components with variants:**

```tsx
import { cva, type VariantProps } from 'class-variance-authority';

const badgeVariants = cva(
	'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold',
	{
		variants: {
			variant: {
				default: 'bg-primary text-primary-foreground',
				secondary: 'bg-secondary text-secondary-foreground',
				destructive: 'bg-destructive text-white',
			},
		},
		defaultVariants: { variant: 'default' },
	},
);

function Badge({
	variant,
	className,
	...props
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
	return (
		<div className={cn(badgeVariants({ variant }), className)} {...props} />
	);
}
```

**Theme tokens** — use semantic CSS variables, not raw colors:

```
bg-background, text-foreground          — base page
bg-card, text-card-foreground           — card surfaces
bg-primary, text-primary-foreground     — primary actions
bg-muted, text-muted-foreground         — subdued/secondary text
bg-destructive, text-destructive        — errors/delete
border, ring                            — borders and focus rings
```

**Dark mode** — via `.dark` class (not `prefers-color-scheme`). Use `dark:` Tailwind variant.

**Animations:**

- Simple: `tailwindcss-animate` classes (`animate-in`, `fade-in`, `slide-in-from-top-2`, etc.)
- Complex/spring: `framer-motion` or `motion` library

**Z-index** — use CSS variable system, not raw values:

```tsx
style={{ zIndex: 'var(--z-modal)' }}
// --z-dropdown: 1000 | --z-modal: 1050 | --z-popover: 10000 | --z-toast: 10100
```

---

## State Management

**Decision rule:**

- **Local `useState`** — UI state: modal open/closed, expanded/collapsed, hover, form field transients
- **Zustand (`useCedarStore`)** — cross-component app state: selected thread, canvas state, drafts, CRM filters
- **TanStack Query** — server data: anything fetched from tRPC
- **Jotai atoms** — isolated search/filter state (minimal usage, only for specific cases like `crmFieldSearchAtom`)

**Zustand usage — always use selectors, never subscribe to the whole store:**

```tsx
// Good — stable reference, only re-renders when this slice changes
const selectedThread = useCedarStore((state) => state.selected);
const setActiveCanvas = useCedarStore((state) => state.setActiveCanvas);

// Common pattern: export convenience selectors from the module
export const useSelectedThread = () => useCedarStore((state) => state.selected);
```

**Zustand slice pattern:**

```tsx
// modules/feature/slice/featureSlice.ts
export interface FeatureSlice {
	items: Item[];
	selectedId: string | null;
	setSelectedId: (id: string | null) => void;
	addItem: (item: Item) => void;
}

export const createFeatureSlice: StateCreator<
	CedarStore,
	[['zustand/immer', never]],
	[],
	FeatureSlice
> = (set) => ({
	items: [],
	selectedId: null,
	setSelectedId: (id) =>
		set((state) => {
			state.selectedId = id;
		}),
	addItem: (item) =>
		set((state) => {
			state.items.push(item);
		}),
});
```

Immer is enabled — mutate state directly inside `set()`.

**useMemo/useCallback** — use for expensive computations and callbacks passed to child components. Don't add them preemptively; add when there's a real performance concern.

---

## tRPC in Components

```tsx
import { useTRPC } from '@/modules/trpc/context';
import { useMutation, useQuery } from '@tanstack/react-query';

function MyComponent() {
	const trpc = useTRPC();

	// Query
	const { data, isLoading } = useQuery(
		trpc.feature.getItems.queryOptions({ filter: 'active' }),
	);

	// Mutation
	const { mutate: createItem } = useMutation(
		trpc.feature.createItem.mutationOptions(),
	);

	const handleCreate = () => {
		createItem(
			{ name: 'New Item' },
			{
				onSuccess: (created) => {
					/* handle */
				},
				onError: (err) => {
					/* handle */
				},
			},
		);
	};
}
```

Query defaults: `staleTime: 1 min`, `gcTime: 24 hr`, `refetchOnWindowFocus: true`. Don't override these unless there's a specific reason.

For user feedback on mutations, use `sonner` toast:

```tsx
import { toast } from 'sonner';
onSuccess: () => toast.success('Item created'),
onError: () => toast.error('Failed to create item'),
```

---

## Forms

Use React Hook Form + Zod + Shadcn Form components:

```tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
	Form,
	FormControl,
	FormField,
	FormItem,
	FormLabel,
	FormMessage,
} from '@/components/ui/form';

const schema = z.object({
	name: z.string().min(1, 'Required'),
});

function MyForm() {
	const form = useForm<z.infer<typeof schema>>({
		resolver: zodResolver(schema),
		defaultValues: { name: '' },
	});

	return (
		<Form {...form}>
			<form
				onSubmit={form.handleSubmit(async (values) => {
					/* submit */
				})}
			>
				<FormField
					control={form.control}
					name='name'
					render={({ field }) => (
						<FormItem>
							<FormLabel>Name</FormLabel>
							<FormControl>
								<Input {...field} />
							</FormControl>
							<FormMessage />
						</FormItem>
					)}
				/>
				<Button type='submit'>Save</Button>
			</form>
		</Form>
	);
}
```

For complex forms, extract logic into a `use-<feature>-form.ts` hook. Use `useWatch` (not `watch`) to subscribe to specific fields without causing full re-renders.

---

## i18n

User-visible strings must use Paraglide:

```tsx
import { m } from '@/paraglide/messages';

// In component
<Button>{m['feature.action.create']()}</Button>
<p>{m['feature.description']()}</p>
```

Messages are functions — call them. Keys follow `feature.subfeature.label` convention. Add new message keys to `messages/en.json` then run `pnpm run build:i18n`.