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:
2026-03-25 15:08:15 +11:00
commit 732c872576
56 changed files with 12690 additions and 0 deletions

568
src/theme/index.ts Normal file
View File

@@ -0,0 +1,568 @@
/**
* FA Design System — MUI Theme
*
* Maps design tokens to MUI's theme structure. All values come from the
* token pipeline: Token JSON → Style Dictionary → generated/tokens.js → here.
*
* To update colours/typography/spacing: edit tokens/ JSON, run `npm run build:tokens`,
* then update references here if token names changed.
*/
import { createTheme } from '@mui/material/styles';
import type { CSSProperties } from 'react';
import * as t from './generated/tokens.js';
/* ---------- Types ---------- */
type StyleWithMedia = CSSProperties & {
[key: `@media ${string}`]: CSSProperties;
};
/* ---------- Custom typography variant declarations ---------- */
declare module '@mui/material/styles' {
interface TypographyVariants {
displayHero: StyleWithMedia;
display1: StyleWithMedia;
display2: StyleWithMedia;
display3: StyleWithMedia;
displaySm: StyleWithMedia;
bodyLg: CSSProperties;
bodyXs: CSSProperties;
labelLg: CSSProperties;
label: CSSProperties;
labelSm: CSSProperties;
captionSm: CSSProperties;
overlineSm: CSSProperties;
}
interface TypographyVariantsOptions {
displayHero?: StyleWithMedia;
display1?: StyleWithMedia;
display2?: StyleWithMedia;
display3?: StyleWithMedia;
displaySm?: StyleWithMedia;
bodyLg?: CSSProperties;
bodyXs?: CSSProperties;
labelLg?: CSSProperties;
label?: CSSProperties;
labelSm?: CSSProperties;
captionSm?: CSSProperties;
overlineSm?: CSSProperties;
}
}
declare module '@mui/material/Typography' {
interface TypographyPropsVariantOverrides {
displayHero: true;
display1: true;
display2: true;
display3: true;
displaySm: true;
bodyLg: true;
bodyXs: true;
labelLg: true;
label: true;
labelSm: true;
captionSm: true;
overlineSm: true;
// Disable old aliases
display: false;
bodyLarge: false;
}
}
/* ---------- Custom Button declarations ---------- */
declare module '@mui/material/Button' {
interface ButtonPropsSizeOverrides {
xs: true;
}
interface ButtonPropsVariantOverrides {
soft: true;
}
}
/* ---------- Theme ---------- */
const MOBILE = '@media (max-width:600px)';
export const theme = createTheme({
palette: {
primary: {
main: t.ColorInteractiveDefault,
dark: t.ColorInteractiveActive,
light: t.ColorBrand400,
contrastText: t.ColorTextInverse,
},
secondary: {
main: t.ColorNeutral600,
dark: t.ColorNeutral700,
light: t.ColorNeutral300,
contrastText: t.ColorTextInverse,
},
error: {
main: t.ColorFeedbackError,
light: t.ColorRed400,
dark: t.ColorRed800,
contrastText: t.ColorTextInverse,
},
warning: {
main: t.ColorFeedbackWarning,
light: t.ColorAmber400,
dark: t.ColorAmber800,
contrastText: t.ColorTextInverse,
},
success: {
main: t.ColorFeedbackSuccess,
light: t.ColorGreen400,
dark: t.ColorGreen800,
contrastText: t.ColorTextInverse,
},
info: {
main: t.ColorFeedbackInfo,
light: t.ColorBlue400,
dark: t.ColorBlue800,
contrastText: t.ColorTextInverse,
},
text: {
primary: t.ColorTextPrimary,
secondary: t.ColorTextSecondary,
disabled: t.ColorTextDisabled,
},
background: {
default: t.ColorSurfaceDefault,
paper: t.ColorSurfaceRaised,
},
divider: t.ColorBorderDefault,
action: {
disabled: t.ColorTextDisabled,
disabledBackground: t.ColorInteractiveDisabled,
},
},
typography: {
fontFamily: t.FontFamilyBody,
/* ── Display (Noto Serif SC, Regular 400) ── */
displayHero: {
fontFamily: t.FontFamilyDisplay,
fontSize: t.TypographyDisplayHeroFontSize,
fontWeight: t.TypographyDisplayHeroFontWeight,
lineHeight: t.TypographyDisplayHeroLineHeight,
letterSpacing: t.TypographyDisplayHeroLetterSpacing,
[MOBILE]: { fontSize: t.TypographyDisplayHeroFontSizeMobile },
},
display1: {
fontFamily: t.FontFamilyDisplay,
fontSize: t.TypographyDisplay1FontSize,
fontWeight: t.TypographyDisplay1FontWeight,
lineHeight: t.TypographyDisplay1LineHeight,
letterSpacing: t.TypographyDisplay1LetterSpacing,
[MOBILE]: { fontSize: t.TypographyDisplay1FontSizeMobile },
},
display2: {
fontFamily: t.FontFamilyDisplay,
fontSize: t.TypographyDisplay2FontSize,
fontWeight: t.TypographyDisplay2FontWeight,
lineHeight: t.TypographyDisplay2LineHeight,
letterSpacing: t.TypographyDisplay2LetterSpacing,
[MOBILE]: { fontSize: t.TypographyDisplay2FontSizeMobile },
},
display3: {
fontFamily: t.FontFamilyDisplay,
fontSize: t.TypographyDisplay3FontSize,
fontWeight: t.TypographyDisplay3FontWeight,
lineHeight: t.TypographyDisplay3LineHeight,
letterSpacing: t.TypographyDisplay3LetterSpacing,
[MOBILE]: { fontSize: t.TypographyDisplay3FontSizeMobile },
},
displaySm: {
fontFamily: t.FontFamilyDisplay,
fontSize: t.TypographyDisplaySmFontSize,
fontWeight: t.TypographyDisplaySmFontWeight,
lineHeight: t.TypographyDisplaySmLineHeight,
letterSpacing: t.TypographyDisplaySmLetterSpacing,
[MOBILE]: { fontSize: t.TypographyDisplaySmFontSizeMobile },
},
/* ── Headings (Montserrat, Bold 700) ── */
h1: {
fontFamily: t.FontFamilyBody,
fontSize: t.TypographyH1FontSize,
fontWeight: t.TypographyH1FontWeight,
lineHeight: t.TypographyH1LineHeight,
letterSpacing: t.TypographyH1LetterSpacing,
[MOBILE]: { fontSize: t.TypographyH1FontSizeMobile },
},
h2: {
fontFamily: t.FontFamilyBody,
fontSize: t.TypographyH2FontSize,
fontWeight: t.TypographyH2FontWeight,
lineHeight: t.TypographyH2LineHeight,
letterSpacing: t.TypographyH2LetterSpacing,
[MOBILE]: { fontSize: t.TypographyH2FontSizeMobile },
},
h3: {
fontFamily: t.FontFamilyBody,
fontSize: t.TypographyH3FontSize,
fontWeight: t.TypographyH3FontWeight,
lineHeight: t.TypographyH3LineHeight,
letterSpacing: t.TypographyH3LetterSpacing,
[MOBILE]: { fontSize: t.TypographyH3FontSizeMobile },
},
h4: {
fontFamily: t.FontFamilyBody,
fontSize: t.TypographyH4FontSize,
fontWeight: t.TypographyH4FontWeight,
lineHeight: t.TypographyH4LineHeight,
letterSpacing: t.TypographyH4LetterSpacing,
[MOBILE]: { fontSize: t.TypographyH4FontSizeMobile },
},
h5: {
fontFamily: t.FontFamilyBody,
fontSize: t.TypographyH5FontSize,
fontWeight: t.TypographyH5FontWeight,
lineHeight: t.TypographyH5LineHeight,
letterSpacing: t.TypographyH5LetterSpacing,
[MOBILE]: { fontSize: t.TypographyH5FontSizeMobile },
},
h6: {
fontFamily: t.FontFamilyBody,
fontSize: t.TypographyH6FontSize,
fontWeight: t.TypographyH6FontWeight,
lineHeight: t.TypographyH6LineHeight,
letterSpacing: t.TypographyH6LetterSpacing,
[MOBILE]: { fontSize: t.TypographyH6FontSizeMobile },
},
/* ── Body (Montserrat, Medium 500) ── */
bodyLg: {
fontFamily: t.FontFamilyBody,
fontSize: t.TypographyBodyLgFontSize,
fontWeight: t.TypographyBodyLgFontWeight,
lineHeight: t.TypographyBodyLgLineHeight,
letterSpacing: t.TypographyBodyLgLetterSpacing,
},
body1: {
fontSize: t.TypographyBodyFontSize,
fontWeight: t.TypographyBodyFontWeight,
lineHeight: t.TypographyBodyLineHeight,
letterSpacing: t.TypographyBodyLetterSpacing,
},
body2: {
fontSize: t.TypographyBodySmFontSize,
fontWeight: t.TypographyBodySmFontWeight,
lineHeight: t.TypographyBodySmLineHeight,
letterSpacing: t.TypographyBodySmLetterSpacing,
},
bodyXs: {
fontFamily: t.FontFamilyBody,
fontSize: t.TypographyBodyXsFontSize,
fontWeight: t.TypographyBodyXsFontWeight,
lineHeight: t.TypographyBodyXsLineHeight,
letterSpacing: t.TypographyBodyXsLetterSpacing,
},
/* ── Subtitle (maps to body variants for MUI compat) ── */
subtitle1: {
fontSize: t.TypographyBodyLgFontSize,
fontWeight: t.TypographyBodyLgFontWeight,
lineHeight: t.TypographyBodyLgLineHeight,
},
subtitle2: {
fontSize: t.TypographyLabelFontSize,
fontWeight: t.TypographyLabelFontWeight,
lineHeight: t.TypographyLabelLineHeight,
letterSpacing: t.TypographyLabelLetterSpacing,
},
/* ── Label (Montserrat, Medium 500) ── */
labelLg: {
fontFamily: t.FontFamilyBody,
fontSize: t.TypographyLabelLgFontSize,
fontWeight: t.TypographyLabelLgFontWeight,
lineHeight: t.TypographyLabelLgLineHeight,
letterSpacing: t.TypographyLabelLgLetterSpacing,
},
label: {
fontFamily: t.FontFamilyBody,
fontSize: t.TypographyLabelFontSize,
fontWeight: t.TypographyLabelFontWeight,
lineHeight: t.TypographyLabelLineHeight,
letterSpacing: t.TypographyLabelLetterSpacing,
},
labelSm: {
fontFamily: t.FontFamilyBody,
fontSize: t.TypographyLabelSmFontSize,
fontWeight: t.TypographyLabelSmFontWeight,
lineHeight: t.TypographyLabelSmLineHeight,
letterSpacing: t.TypographyLabelSmLetterSpacing,
},
/* ── Caption (Montserrat, Regular 400) ── */
caption: {
fontSize: t.TypographyCaptionFontSize,
fontWeight: t.TypographyCaptionFontWeight,
lineHeight: t.TypographyCaptionLineHeight,
letterSpacing: t.TypographyCaptionLetterSpacing,
},
captionSm: {
fontFamily: t.FontFamilyBody,
fontSize: t.TypographyCaptionSmFontSize,
fontWeight: t.TypographyCaptionSmFontWeight,
lineHeight: t.TypographyCaptionSmLineHeight,
letterSpacing: t.TypographyCaptionSmLetterSpacing,
},
/* ── Overline (Montserrat, SemiBold 600, uppercase) ── */
overline: {
fontSize: t.TypographyOverlineFontSize,
fontWeight: t.TypographyOverlineFontWeight,
lineHeight: t.TypographyOverlineLineHeight,
letterSpacing: t.TypographyOverlineLetterSpacing,
textTransform: 'uppercase' as const,
},
overlineSm: {
fontFamily: t.FontFamilyBody,
fontSize: t.TypographyOverlineSmFontSize,
fontWeight: t.TypographyOverlineSmFontWeight,
lineHeight: t.TypographyOverlineSmLineHeight,
letterSpacing: t.TypographyOverlineSmLetterSpacing,
textTransform: 'uppercase' as const,
},
/* ── Button text ── */
button: {
fontWeight: 600,
letterSpacing: '0.02em',
textTransform: 'none' as const,
},
},
spacing: 4,
shape: {
borderRadius: parseInt(t.BorderRadiusMd, 10),
},
components: {
MuiButton: {
defaultProps: {
disableElevation: true,
},
styleOverrides: {
root: {
borderRadius: parseInt(t.ButtonBorderRadiusDefault, 10),
textTransform: 'none' as const,
fontWeight: 600,
letterSpacing: '0.02em',
transition: 'background-color 150ms ease-in-out, border-color 150ms ease-in-out, box-shadow 150ms ease-in-out',
'&:focus-visible': {
outline: `2px solid ${t.ColorInteractiveFocus}`,
outlineOffset: '2px',
},
},
sizeSmall: {
minHeight: parseInt(t.ButtonHeightSm, 10),
padding: `${t.ButtonPaddingYSm} ${t.ButtonPaddingXSm}`,
fontSize: t.ButtonFontSizeSm,
'& .MuiButton-startIcon': { marginRight: t.ButtonIconGapSm },
'& .MuiButton-endIcon': { marginLeft: t.ButtonIconGapSm },
'& .MuiButton-startIcon > *:nth-of-type(1), & .MuiButton-endIcon > *:nth-of-type(1)': {
fontSize: t.ButtonIconSizeSm,
},
},
sizeMedium: {
minHeight: parseInt(t.ButtonHeightMd, 10),
padding: `${t.ButtonPaddingYMd} ${t.ButtonPaddingXMd}`,
fontSize: t.ButtonFontSizeMd,
'& .MuiButton-startIcon': { marginRight: t.ButtonIconGapMd },
'& .MuiButton-endIcon': { marginLeft: t.ButtonIconGapMd },
'& .MuiButton-startIcon > *:nth-of-type(1), & .MuiButton-endIcon > *:nth-of-type(1)': {
fontSize: t.ButtonIconSizeMd,
},
},
sizeLarge: {
minHeight: parseInt(t.ButtonHeightLg, 10),
padding: `${t.ButtonPaddingYLg} ${t.ButtonPaddingXLg}`,
fontSize: t.ButtonFontSizeLg,
'& .MuiButton-startIcon': { marginRight: t.ButtonIconGapLg },
'& .MuiButton-endIcon': { marginLeft: t.ButtonIconGapLg },
'& .MuiButton-startIcon > *:nth-of-type(1), & .MuiButton-endIcon > *:nth-of-type(1)': {
fontSize: t.ButtonIconSizeLg,
},
},
containedPrimary: {
'&:hover': { backgroundColor: t.ColorInteractiveHover },
'&:active': { backgroundColor: t.ColorInteractiveActive },
},
containedSecondary: {
'&:hover': { backgroundColor: t.ColorNeutral700 },
},
outlinedPrimary: {
borderColor: t.ColorInteractiveDefault,
color: t.ColorInteractiveDefault,
'&:hover': {
borderColor: t.ColorInteractiveHover,
color: t.ColorInteractiveHover,
backgroundColor: t.ColorBrand100,
},
'&:active': {
borderColor: t.ColorInteractiveActive,
color: t.ColorInteractiveActive,
},
},
outlinedSecondary: {
borderColor: t.ColorNeutral400,
color: t.ColorNeutral600,
'&:hover': {
borderColor: t.ColorNeutral600,
color: t.ColorNeutral700,
backgroundColor: t.ColorNeutral200,
},
},
textPrimary: {
color: t.ColorInteractiveDefault,
'&:hover': {
color: t.ColorInteractiveHover,
backgroundColor: t.ColorBrand100,
},
'&:active': {
color: t.ColorInteractiveActive,
},
},
textSecondary: {
color: t.ColorNeutral600,
'&:hover': {
color: t.ColorNeutral700,
backgroundColor: t.ColorNeutral200,
},
},
},
variants: [
{
props: { size: 'xs' as const },
style: {
minHeight: parseInt(t.ButtonHeightXs, 10),
padding: `${t.ButtonPaddingYXs} ${t.ButtonPaddingXXs}`,
fontSize: t.ButtonFontSizeXs,
'& .MuiButton-startIcon': { marginRight: t.ButtonIconGapXs },
'& .MuiButton-endIcon': { marginLeft: t.ButtonIconGapXs },
'& .MuiButton-startIcon > *:nth-of-type(1), & .MuiButton-endIcon > *:nth-of-type(1)': {
fontSize: t.ButtonIconSizeXs,
},
},
},
{
props: { variant: 'soft' as const, color: 'primary' as const },
style: {
backgroundColor: t.ColorBrand200,
color: t.ColorInteractiveHover,
'&:hover': { backgroundColor: t.ColorBrand300 },
'&:active': { backgroundColor: t.ColorBrand400 },
},
},
{
props: { variant: 'soft' as const, color: 'secondary' as const },
style: {
backgroundColor: t.ColorNeutral200,
color: t.ColorNeutral700,
'&:hover': { backgroundColor: t.ColorNeutral300 },
'&:active': { backgroundColor: t.ColorNeutral400, color: t.ColorNeutral700 },
},
},
],
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: parseInt(t.BorderRadiusMd, 10),
},
},
},
MuiOutlinedInput: {
styleOverrides: {
root: {
borderRadius: parseInt(t.InputBorderRadiusDefault, 10),
transition: 'box-shadow 150ms ease-in-out',
// Default (medium) height
minHeight: parseInt(t.InputHeightMd, 10),
// Small size
'&.MuiInputBase-sizeSmall': {
minHeight: parseInt(t.InputHeightSm, 10),
},
// Default border
'& .MuiOutlinedInput-notchedOutline': {
borderColor: t.ColorNeutral300,
transition: 'border-color 150ms ease-in-out',
},
// Hover — darker border (skip when focused, error, or disabled)
'&:hover:not(.Mui-focused):not(.Mui-error):not(.Mui-disabled) .MuiOutlinedInput-notchedOutline': {
borderColor: t.ColorNeutral400,
},
// Focus — brand gold border + double ring (white gap + brand ring)
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: t.ColorBrand500,
borderWidth: '1px',
},
'&.Mui-focused': {
boxShadow: `0 0 0 3px ${t.ColorWhite}, 0 0 0 5px ${t.ColorBrand500}`,
},
// Error border
'&.Mui-error .MuiOutlinedInput-notchedOutline': {
borderColor: t.ColorFeedbackError,
borderWidth: '1px',
},
// Error + focused — error-coloured ring
'&.Mui-error.Mui-focused': {
boxShadow: `0 0 0 3px ${t.ColorWhite}, 0 0 0 5px ${t.ColorFeedbackError}`,
},
// Disabled — muted background
'&.Mui-disabled': {
backgroundColor: t.ColorNeutral200,
'& .MuiOutlinedInput-notchedOutline': {
borderColor: t.ColorNeutral300,
},
},
// Adornment icon sizing
'& .MuiInputAdornment-root': {
color: t.ColorNeutral400,
'& .MuiSvgIcon-root': {
fontSize: t.InputIconSizeDefault,
},
},
},
input: {
padding: `${t.InputPaddingYMd} ${t.InputPaddingXDefault}`,
fontSize: t.InputFontSizeDefault,
color: t.ColorTextPrimary,
'&::placeholder': {
color: t.ColorNeutral400,
opacity: 1,
},
// Small size padding
'.MuiInputBase-sizeSmall &': {
padding: `${t.InputPaddingYSm} ${t.InputPaddingXDefault}`,
},
},
notchedOutline: {
// Reset top offset — MUI defaults to -5px for floating label space.
// We use external labels, so the border should be flush with the input.
top: 0,
// Hide the notch legend — we use external labels
'& legend': {
display: 'none',
},
},
multiline: {
padding: 0,
'& .MuiOutlinedInput-input': {
padding: `${t.InputPaddingYMd} ${t.InputPaddingXDefault}`,
},
},
},
},
},
});
export default theme;