Files
Parsons/src/theme/index.ts
Richie 7169a6559b Add Card atom component
- Create card component tokens (borderRadius, padding, shadow, border, background)
- Build Card component with elevated/outlined variants, interactive hover, padding presets
- Add MUI theme overrides using card tokens (shadow.md resting, border for outlined)
- Create 8 Storybook stories: Default, Variants, Interactive, PaddingPresets,
  PriceCardPreview, ServiceOptionPreview, WithImage, RichContent
- Regenerate token pipeline outputs (7 new card tokens)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:31:10 +11:00

581 lines
19 KiB
TypeScript

/**
* 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.CardBorderRadiusDefault, 10),
backgroundColor: t.CardBackgroundDefault,
boxShadow: t.CardShadowDefault,
transition: 'box-shadow 150ms ease-in-out',
},
},
variants: [
{
props: { variant: 'outlined' },
style: {
boxShadow: 'none',
borderColor: t.CardBorderDefault,
},
},
],
},
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;