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

8.2 KiB
Raw Permalink Blame History

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:

export { default } from './Button';
export * from './Button';

Component template

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:

// 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():

// 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:

const StyledCard = styled(Card)(({ theme }) => ({
  padding: theme.spacing(3),
  [theme.breakpoints.up('md')]: {
    padding: theme.spacing(4),
  },
}));

Storybook story template

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