Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 65 additions & 2 deletions src/__testing__/theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
readableTextColor,
SistentDefaultPrimitivePaletteLight
} from '../theme';
import { MuiButton } from '../theme/components/button.modifier';

const LIGHT_INK = SistentDefaultPrimitivePaletteLight.primaryInverted; // charcoal[100] ≈ near-white
const DARK_INK = SistentDefaultPrimitivePaletteLight.foreground; // charcoal[10] ≈ near-black
Expand All @@ -28,7 +29,7 @@ describe('readableTextColor', () => {
describe('createCustomTheme contrast derivation', () => {
it('derives readable body text when a brand omits the contrast tokens', () => {
// Only base colors provided; background is dark. The derived foreground
// (text.default) must be light so text stays readable not the Layer5
// (text.default) must be light so text stays readable - not the Layer5
// default dark ink.
const theme = createCustomTheme('light', {
navigationBar: '#101010',
Expand Down Expand Up @@ -57,7 +58,7 @@ describe('createCustomTheme contrast derivation', () => {

it('keeps default contrast tokens for base colors the caller did not customize', () => {
// Only `background` is overridden, so its contrast token (foreground)
// re-derives but `primary` is untouched, so its contrast token must
// re-derives - but `primary` is untouched, so its contrast token must
// keep the default ink rather than silently re-deriving from KEPPEL.
const theme = createCustomTheme('light', { background: '#000000' });

Expand All @@ -81,3 +82,65 @@ describe('createCustomTheme contrast derivation', () => {
expect(theme.palette.mode).toBe('light');
});
});

describe('MuiButton contained honors the semantic color prop', () => {
// The MuiButton override function computes styles from the active theme.
// Call it directly (MUI would call it as `root({ theme, ownerState })`) so the
// assertions read the resolved backgroundColor per contained variant.
const rootStyles = (mode: 'light' | 'dark') => {
const theme = createCustomTheme(mode);
const root = MuiButton.styleOverrides!.root as (arg: { theme: typeof theme }) => Record<
string,
{ color?: string; backgroundColor?: string; ['&:hover']?: { backgroundColor?: string } }
>;
return { theme, styles: root({ theme }) };
Comment on lines +92 to +96

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cast is deliberately scoped to the selector shape the test asserts (color / backgroundColor / &:hover); the test only ever indexes selector keys, never fontWeight / lineHeight. Broadening to Record<string, unknown> would add a cast at every access without catching a real bug, so I've kept the narrower shape. Happy to switch if you'd prefer the stricter typing.

};

it('paints color="error" contained buttons with the error background, not the brand (keppel)', () => {
const { theme, styles } = rootStyles('light');
const contained = styles['&.MuiButton-contained'];
const containedError = styles['&.MuiButton-containedError'];

expect(containedError).toBeDefined();
// Uses the semantic error background...
expect(containedError.backgroundColor).toBe(theme.palette.background.error.default);
expect(containedError['&:hover']?.backgroundColor).toBe(
theme.palette.background.error.hover
);
// ...and is NOT the brand background that `&.MuiButton-contained` sets - the
// regression that made `<Button variant="contained" color="error">` green.
expect(containedError.backgroundColor).not.toBe(contained.backgroundColor);
});

it('also honors success, warning, and info contained buttons', () => {
const { theme, styles } = rootStyles('light');
expect(styles['&.MuiButton-containedSuccess'].backgroundColor).toBe(
theme.palette.background.success.default
);
expect(styles['&.MuiButton-containedWarning'].backgroundColor).toBe(
theme.palette.background.warning.default
);
expect(styles['&.MuiButton-containedInfo'].backgroundColor).toBe(
theme.palette.background.info.default
);
});

it('derives a readable (WCAG) label colour per background, not a hardcoded white', () => {
const { theme, styles } = rootStyles('light');
// The light yellow warning background must get dark text, not an unreadable
// white one - getContrastText picks per luminance.
expect(styles['&.MuiButton-containedWarning'].color).toBe(
theme.palette.getContrastText(theme.palette.background.warning.default)
);
expect(styles['&.MuiButton-containedError'].color).toBe(
theme.palette.getContrastText(theme.palette.background.error.default)
);
});

it('resolves the error background in dark mode too', () => {
const { theme, styles } = rootStyles('dark');
expect(styles['&.MuiButton-containedError'].backgroundColor).toBe(
theme.palette.background.error.default
);
});
});
45 changes: 44 additions & 1 deletion src/theme/components/button.modifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export const MuiButton: Components<Theme>['MuiButton'] = {
root: ({ theme }) => {
const {
palette: {
background: { brand, hover },
background: { brand, hover, error, success, warning, info },
text: { disabled, constant, neutral: TextNeutral },
border: { neutral }
},
Expand All @@ -22,6 +22,49 @@ export const MuiButton: Components<Theme>['MuiButton'] = {
backgroundColor: brand?.hover
}
},
// Contained buttons default to the brand colour above. Honour the
// semantic `color` prop so error / success / warning / info actions are
// not silently painted brand (keppel) - the long-standing gap that made
// `<Button variant="contained" color="error">` render green. Text colour
// is derived per-background with getContrastText so a light background
// (notably the yellow warning) keeps a readable dark label instead of an
// unreadable white one (WCAG). These rules share the
// `&.MuiButton-contained` selector's (0,0,2,0) specificity and follow it
// in source order, so a semantic-colour button (which carries BOTH
// classes) resolves to its colour; the higher-specificity `.Mui-disabled`
// rules below still win when disabled.
'&.MuiButton-containedError': {
color: error?.default ? theme.palette.getContrastText(error.default) : constant?.white,
backgroundColor: error?.default,
'&:hover': {
backgroundColor: error?.hover
}
},
'&.MuiButton-containedSuccess': {
color: success?.default
? theme.palette.getContrastText(success.default)
: constant?.white,
backgroundColor: success?.default,
'&:hover': {
backgroundColor: success?.hover
}
},
'&.MuiButton-containedWarning': {
color: warning?.default
? theme.palette.getContrastText(warning.default)
: constant?.white,
backgroundColor: warning?.default,
'&:hover': {
backgroundColor: warning?.hover
}
},
Comment on lines +36 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This block has two main issues:

  1. Accessibility / Contrast Issue: Hardcoding color: constant?.white (near-white) for the warning button results in white text on a yellow background (Colors.yellow[30] / Colors.yellow[40]), which severely violates WCAG contrast guidelines and makes the text unreadable.
  2. Missing info Button: The info semantic color is also supported by MUI buttons but was omitted, meaning <Button variant="contained" color="info"> would still incorrectly render with the brand green background.

To solve both issues robustly, we can use MUI's built-in theme.palette.getContrastText to dynamically calculate the correct WCAG-compliant text color for all semantic buttons (error, success, warning, info), and add the missing &.MuiButton-containedInfo override.

        '&.MuiButton-containedError': {
          color: error?.default ? theme.palette.getContrastText(error.default) : constant?.white,
          backgroundColor: error?.default,
          '&:hover': {
            backgroundColor: error?.hover
          }
        },
        '&.MuiButton-containedSuccess': {
          color: success?.default ? theme.palette.getContrastText(success.default) : constant?.white,
          backgroundColor: success?.default,
          '&:hover': {
            backgroundColor: success?.hover
          }
        },
        '&.MuiButton-containedWarning': {
          color: warning?.default ? theme.palette.getContrastText(warning.default) : constant?.white,
          backgroundColor: warning?.default,
          '&:hover': {
            backgroundColor: warning?.hover
          }
        },
        '&.MuiButton-containedInfo': {
          color: info?.default ? theme.palette.getContrastText(info.default) : constant?.white,
          backgroundColor: info?.default,
          '&:hover': {
            backgroundColor: info?.hover
          }
        },

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch on both. Adopted theme.palette.getContrastText(...) for the label colour on all four semantic variants (so the yellow warning gets readable dark text instead of white), and added &.MuiButton-containedInfo. Done in d2e8e8c.

'&.MuiButton-containedInfo': {
color: info?.default ? theme.palette.getContrastText(info.default) : constant?.white,
backgroundColor: info?.default,
'&:hover': {
backgroundColor: info?.hover
}
},
'&.MuiButton-outlined': {
border: `1px solid ${neutral?.default}`,
'&:hover': {
Expand Down
Loading