Files
Parsons/docs/conventions/component-conventions.md
Richie aa7cdeecf0 Add workflow infrastructure — ESLint, Prettier, Husky, Vitest, 7 new skills
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>
2026-03-27 16:41:57 +11:00

244 lines
8.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.)