Initial commit: FA 2.0 Design System foundation
Token pipeline (Style Dictionary v4, DTCG format): - Primitive tokens: colour palettes (brand, sage, neutral, feedback), typography (3 font families, 21-variant type scale), spacing (4px grid), border radius, shadows, opacity - Semantic tokens: text, surface, border, interactive, feedback colours; typography roles; layout spacing - Component tokens: Button (4 sizes), Input (2 sizes) - Generated outputs: CSS custom properties, JS ES6 module, flat JSON Atoms (3 components): - Button: contained/soft/outlined/text × primary/secondary, 4 sizes, loading state, underline for text variant - Typography: 21 variants across display/heading/body/label/caption/overline, maxLines truncation - Input: external label, helper text, error/success validation, start/end icons, required indicator, 2 sizes, multiline support Infrastructure: - MUI v5 theme with full token mapping - Storybook 8 with autodocs - Claude Code agents and skills for token/component workflows - Design system documentation and cross-session memory Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
241
docs/conventions/component-conventions.md
Normal file
241
docs/conventions/component-conventions.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# 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
|
||||
- Use `theme.palette.*`, `theme.spacing()`, `theme.typography.*`, `theme.shape.*`
|
||||
- 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.)
|
||||
144
docs/conventions/token-conventions.md
Normal file
144
docs/conventions/token-conventions.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Token conventions
|
||||
|
||||
These conventions MUST be followed by any agent creating or modifying tokens.
|
||||
|
||||
## W3C DTCG format
|
||||
|
||||
All tokens use the DTCG JSON format. Every token has `$value`, `$type`, and `$description`.
|
||||
|
||||
```json
|
||||
{
|
||||
"color": {
|
||||
"brand": {
|
||||
"primary": {
|
||||
"$value": "#1B4965",
|
||||
"$type": "color",
|
||||
"$description": "Primary brand colour — deep navy. Used for primary actions, key headings, and trust-building UI elements."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Naming rules
|
||||
|
||||
### General
|
||||
- Use dot notation for hierarchy in documentation: `color.brand.primary`
|
||||
- In JSON, dots become nested objects
|
||||
- Names are lowercase, no spaces, no special characters except hyphens for compound words
|
||||
- Names must be descriptive of purpose (semantic) or scale position (primitive)
|
||||
|
||||
### Primitive naming
|
||||
Primitives describe WHAT the value is, not WHERE it's used.
|
||||
|
||||
```
|
||||
color.{hue}.{scale} → color.blue.500, color.neutral.100
|
||||
spacing.{scale} → spacing.1, spacing.2, spacing.4, spacing.8
|
||||
fontSize.{scale} → fontSize.xs, fontSize.sm, fontSize.base, fontSize.lg
|
||||
fontWeight.{name} → fontWeight.regular, fontWeight.medium, fontWeight.bold
|
||||
fontFamily.{purpose} → fontFamily.heading, fontFamily.body, fontFamily.mono
|
||||
borderRadius.{scale} → borderRadius.sm, borderRadius.md, borderRadius.lg, borderRadius.full
|
||||
shadow.{scale} → shadow.sm, shadow.md, shadow.lg
|
||||
lineHeight.{scale} → lineHeight.tight, lineHeight.normal, lineHeight.relaxed
|
||||
letterSpacing.{scale} → letterSpacing.tight, letterSpacing.normal, letterSpacing.wide
|
||||
opacity.{scale} → opacity.disabled, opacity.hover, opacity.overlay
|
||||
```
|
||||
|
||||
### Colour scale convention
|
||||
Use a 50-950 scale (matching Tailwind/MUI convention):
|
||||
- 50: Lightest tint (backgrounds, subtle fills)
|
||||
- 100-200: Light tints (hover states, borders)
|
||||
- 300-400: Mid tones (secondary text, icons)
|
||||
- 500: Base/reference value
|
||||
- 600-700: Strong tones (primary text on light bg, active states)
|
||||
- 800-900: Darkest shades (headings, high-contrast text)
|
||||
- 950: Near-black (used sparingly)
|
||||
|
||||
### Semantic naming
|
||||
Semantic tokens describe WHERE and WHY a value is used.
|
||||
|
||||
```
|
||||
color.text.{variant} → color.text.primary, color.text.secondary, color.text.disabled, color.text.inverse
|
||||
color.surface.{variant} → color.surface.default, color.surface.raised, color.surface.overlay
|
||||
color.border.{variant} → color.border.default, color.border.strong, color.border.subtle
|
||||
color.interactive.{state} → color.interactive.default, color.interactive.hover, color.interactive.active, color.interactive.disabled
|
||||
color.feedback.{type} → color.feedback.success, color.feedback.warning, color.feedback.error, color.feedback.info
|
||||
spacing.component.{size} → spacing.component.xs, spacing.component.sm, spacing.component.md, spacing.component.lg
|
||||
spacing.layout.{size} → spacing.layout.section, spacing.layout.page, spacing.layout.gutter
|
||||
typography.{role} → typography.display, typography.heading, typography.body, typography.caption, typography.label
|
||||
```
|
||||
|
||||
### Component token naming
|
||||
Component tokens are scoped to a specific component.
|
||||
|
||||
```
|
||||
{component}.{property}.{state}
|
||||
button.background.default
|
||||
button.background.hover
|
||||
button.background.active
|
||||
button.background.disabled
|
||||
button.text.default
|
||||
button.text.disabled
|
||||
button.border.default
|
||||
button.border.focus
|
||||
button.padding.horizontal
|
||||
button.padding.vertical
|
||||
button.borderRadius
|
||||
card.background.default
|
||||
card.border.default
|
||||
card.padding
|
||||
card.borderRadius
|
||||
card.shadow
|
||||
input.background.default
|
||||
input.background.focus
|
||||
input.border.default
|
||||
input.border.error
|
||||
input.border.focus
|
||||
input.text.default
|
||||
input.text.placeholder
|
||||
```
|
||||
|
||||
## Alias rules
|
||||
|
||||
- Semantic tokens MUST reference primitives (never hardcode values)
|
||||
- Component tokens MUST reference semantic tokens (never reference primitives directly)
|
||||
- This creates the chain: component → semantic → primitive
|
||||
- Exception: spacing and borderRadius component tokens may reference primitives directly when the semantic layer adds no value
|
||||
|
||||
```json
|
||||
// CORRECT: component → semantic → primitive
|
||||
"button.background.default": { "$value": "{color.interactive.default}" }
|
||||
"color.interactive.default": { "$value": "{color.brand.primary}" }
|
||||
"color.brand.primary": { "$value": "{color.blue.700}" }
|
||||
"color.blue.700": { "$value": "#1B4965" }
|
||||
|
||||
// WRONG: component referencing a primitive directly
|
||||
"button.background.default": { "$value": "{color.blue.700}" }
|
||||
```
|
||||
|
||||
## Accessibility requirements
|
||||
|
||||
- All colour combinations used for text must meet WCAG 2.1 AA contrast ratio (4.5:1 for normal text, 3:1 for large text)
|
||||
- Interactive elements must have a visible focus indicator
|
||||
- Disabled states must still be distinguishable from enabled states
|
||||
- When creating colour tokens, note the contrast ratio with common background colours in the `$description`
|
||||
|
||||
## File organisation
|
||||
|
||||
```
|
||||
tokens/
|
||||
├── primitives/
|
||||
│ ├── colours.json # All colour primitives (brand, neutral, feedback hues)
|
||||
│ ├── typography.json # Font families, sizes, weights, line heights
|
||||
│ ├── spacing.json # Spacing scale, border radius, sizing
|
||||
│ └── effects.json # Shadows, opacity values
|
||||
├── semantic/
|
||||
│ ├── colours.json # Semantic colour mappings
|
||||
│ ├── typography.json # Typography role mappings
|
||||
│ └── spacing.json # Layout and component spacing
|
||||
└── component/
|
||||
├── button.json # Button-specific tokens
|
||||
├── input.json # Input-specific tokens
|
||||
├── card.json # Card-specific tokens
|
||||
└── ... # One file per component that needs specific tokens
|
||||
```
|
||||
Reference in New Issue
Block a user