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