Phase 1: Session log archived (1096→91 lines), D031 token access convention
Phase 2: ESLint v9 + Prettier + jsx-a11y, initial config and lint fixes
Phase 3: 7 new skills (polish, harden, normalize, clarify, typeset, quieter, adapt)
+ Vercel reference docs, updated audit/review-component refs
Phase 4: Husky + lint-staged pre-commit hooks, preflight updated to 8 checks
Phase 5: Vitest + Testing Library + /write-tests skill
- Badge.tsx colour maps unified to CSS variables (D031)
- 5 empty interface→type alias fixes (Switch, Radio, Divider, IconButton, Link)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
244 lines
8.2 KiB
Markdown
244 lines
8.2 KiB
Markdown
# Component conventions
|
||
|
||
These conventions MUST be followed by any agent creating or modifying components.
|
||
|
||
## File structure
|
||
|
||
Every component lives in its own folder:
|
||
|
||
```
|
||
src/components/atoms/Button/
|
||
├── Button.tsx # The component
|
||
├── Button.stories.tsx # Storybook stories
|
||
├── Button.test.tsx # Unit tests (optional, added later)
|
||
└── index.ts # Re-export
|
||
```
|
||
|
||
The `index.ts` always looks like:
|
||
```typescript
|
||
export { default } from './Button';
|
||
export * from './Button';
|
||
```
|
||
|
||
## Component template
|
||
|
||
```typescript
|
||
import React from 'react';
|
||
import { ButtonBase, ButtonBaseProps } from '@mui/material';
|
||
import { styled, useTheme } from '@mui/material/styles';
|
||
|
||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||
|
||
/** Props for the Button component */
|
||
export interface ButtonProps extends Omit<ButtonBaseProps, 'color'> {
|
||
/** Visual style variant */
|
||
variant?: 'contained' | 'outlined' | 'text';
|
||
/** Size preset */
|
||
size?: 'small' | 'medium' | 'large';
|
||
/** Colour intent */
|
||
color?: 'primary' | 'secondary' | 'neutral';
|
||
/** Show loading spinner and disable interaction */
|
||
loading?: boolean;
|
||
/** Full width of parent container */
|
||
fullWidth?: boolean;
|
||
}
|
||
|
||
// ─── Styled wrapper (if needed) ──────────────────────────────────────────────
|
||
|
||
const StyledButton = styled(ButtonBase, {
|
||
shouldForwardProp: (prop) =>
|
||
!['variant', 'size', 'color', 'loading', 'fullWidth'].includes(prop as string),
|
||
})<ButtonProps>(({ theme, variant, size, color }) => ({
|
||
// ALL values come from theme — never hardcode
|
||
borderRadius: theme.shape.borderRadius,
|
||
fontFamily: theme.typography.fontFamily,
|
||
fontWeight: theme.typography.fontWeightMedium,
|
||
transition: theme.transitions.create(
|
||
['background-color', 'box-shadow', 'border-color'],
|
||
{ duration: theme.transitions.duration.short }
|
||
),
|
||
// ... variant-specific styles using theme values
|
||
}));
|
||
|
||
// ─── Component ───────────────────────────────────────────────────────────────
|
||
|
||
/** Primary action button for the FA design system */
|
||
export const Button: React.FC<ButtonProps> = ({
|
||
variant = 'contained',
|
||
size = 'medium',
|
||
color = 'primary',
|
||
loading = false,
|
||
fullWidth = false,
|
||
children,
|
||
disabled,
|
||
...props
|
||
}) => {
|
||
return (
|
||
<StyledButton
|
||
variant={variant}
|
||
size={size}
|
||
color={color}
|
||
loading={loading}
|
||
fullWidth={fullWidth}
|
||
disabled={loading || disabled}
|
||
{...props}
|
||
>
|
||
{loading ? 'Loading…' : children}
|
||
</StyledButton>
|
||
);
|
||
};
|
||
|
||
export default Button;
|
||
```
|
||
|
||
## Rules
|
||
|
||
### Theming
|
||
- **NEVER hardcode** colour values, spacing, font sizes, shadows, or border radii
|
||
- **Semantic tokens** (text, surface, border, interactive, feedback, typography, spacing): use MUI theme accessors — `theme.palette.*`, `theme.spacing()`, `theme.typography.*`, `theme.shape.*` — when inside a theme callback. CSS variables (`var(--fa-color-*)`, `var(--fa-spacing-*)`) are also acceptable for semantic tokens when more ergonomic (e.g., static colour maps, non-callback contexts).
|
||
- **Component tokens** (badge sizes, card shadows, input dimensions, etc.): use CSS variables (`var(--fa-badge-*)`, `var(--fa-card-*)`) — these are NOT mapped into the MUI theme.
|
||
- See decision D031 in `docs/memory/decisions-log.md` for full rationale.
|
||
- For one-off theme overrides, use the `sx` prop pattern
|
||
- Every component MUST accept and forward the `sx` prop for consumer overrides
|
||
|
||
### TypeScript
|
||
- Every prop must have a JSDoc description
|
||
- Use interface (not type) for props — interfaces produce better autodocs
|
||
- Export both the component and the props interface
|
||
- Use `Omit<>` to remove conflicting MUI base props
|
||
|
||
### Composition
|
||
- Prefer composition over configuration
|
||
- Small, focused components that compose well
|
||
- Avoid god-components with 20+ props — split into variants or sub-components
|
||
- Use React.forwardRef for all interactive elements (buttons, inputs, links)
|
||
|
||
### Accessibility
|
||
- All interactive elements must have a minimum 44×44px touch target
|
||
- Always include `aria-label` or visible label text
|
||
- Buttons must have `type="button"` unless they're form submit buttons
|
||
- Focus indicators must be visible — never remove outline without replacement
|
||
- Disabled elements should use `aria-disabled` alongside visual treatment
|
||
|
||
### MUI integration patterns
|
||
|
||
**When wrapping MUI components:**
|
||
```typescript
|
||
// Extend MUI's own props type
|
||
interface ButtonProps extends Omit<MuiButtonProps, 'color'> {
|
||
// Add your custom props, omitting any MUI props you're overriding
|
||
}
|
||
|
||
// Forward all unknown props to MUI
|
||
const Button: React.FC<ButtonProps> = ({ customProp, ...muiProps }) => {
|
||
return <MuiButton {...muiProps} />;
|
||
};
|
||
```
|
||
|
||
**When building from scratch with styled():**
|
||
```typescript
|
||
// Use shouldForwardProp to prevent custom props leaking to DOM
|
||
const StyledDiv = styled('div', {
|
||
shouldForwardProp: (prop) => prop !== 'isActive',
|
||
})<{ isActive?: boolean }>(({ theme, isActive }) => ({
|
||
backgroundColor: isActive
|
||
? theme.palette.primary.main
|
||
: theme.palette.background.paper,
|
||
}));
|
||
```
|
||
|
||
**Theme-aware responsive styles:**
|
||
```typescript
|
||
const StyledCard = styled(Card)(({ theme }) => ({
|
||
padding: theme.spacing(3),
|
||
[theme.breakpoints.up('md')]: {
|
||
padding: theme.spacing(4),
|
||
},
|
||
}));
|
||
```
|
||
|
||
## Storybook story template
|
||
|
||
```typescript
|
||
import type { Meta, StoryObj } from '@storybook/react';
|
||
import { Button } from './Button';
|
||
|
||
const meta: Meta<typeof Button> = {
|
||
title: 'Atoms/Button', // Tier/ComponentName
|
||
component: Button,
|
||
tags: ['autodocs'], // Always include for auto-documentation
|
||
parameters: {
|
||
layout: 'centered', // Use 'centered' for atoms, 'fullscreen' for layouts
|
||
},
|
||
argTypes: {
|
||
variant: {
|
||
control: 'select',
|
||
options: ['contained', 'outlined', 'text'],
|
||
description: 'Visual style variant',
|
||
table: { defaultValue: { summary: 'contained' } },
|
||
},
|
||
// ... one entry per prop
|
||
onClick: { action: 'clicked' }, // Log actions for event props
|
||
},
|
||
};
|
||
|
||
export default meta;
|
||
type Story = StoryObj<typeof Button>;
|
||
|
||
// ─── Individual stories ──────────────────────────────────────────────────────
|
||
|
||
/** Default button appearance */
|
||
export const Default: Story = {
|
||
args: { children: 'Get started' },
|
||
};
|
||
|
||
/** All visual variants side by side */
|
||
export const AllVariants: Story = {
|
||
render: () => (
|
||
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||
<Button variant="contained">Contained</Button>
|
||
<Button variant="outlined">Outlined</Button>
|
||
<Button variant="text">Text</Button>
|
||
</div>
|
||
),
|
||
};
|
||
|
||
/** All sizes side by side */
|
||
export const AllSizes: Story = {
|
||
render: () => (
|
||
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||
<Button size="small">Small</Button>
|
||
<Button size="medium">Medium</Button>
|
||
<Button size="large">Large</Button>
|
||
</div>
|
||
),
|
||
};
|
||
|
||
/** Interactive states */
|
||
export const Disabled: Story = {
|
||
args: { children: 'Disabled', disabled: true },
|
||
};
|
||
|
||
export const Loading: Story = {
|
||
args: { children: 'Submitting', loading: true },
|
||
};
|
||
```
|
||
|
||
### Story naming conventions
|
||
- `title`: Use atomic tier as prefix: `Atoms/Button`, `Molecules/PriceCard`, `Organisms/PricingTable`
|
||
- Individual stories: PascalCase, descriptive of the state or variant shown
|
||
- Always include: `Default`, `AllVariants` (if applicable), `AllSizes` (if applicable), `Disabled`
|
||
- For composed components, include a story showing the component with realistic content
|
||
|
||
### Story coverage checklist
|
||
For every component, stories must cover:
|
||
- [ ] Default state with typical content
|
||
- [ ] All visual variants side by side
|
||
- [ ] All sizes side by side (if applicable)
|
||
- [ ] Disabled state
|
||
- [ ] Loading state (if applicable)
|
||
- [ ] Error state (if applicable)
|
||
- [ ] Long content / content overflow
|
||
- [ ] Empty/minimal content
|
||
- [ ] With and without optional elements (icons, badges, etc.)
|