Files
app/src/admin/theme.ts
BOHA 54f3c414f5 fix(ui): unify icon badges — solid tile + white glyph via shared helper
The labelled icon-badge tiles were inconsistent: some used a same-hue
light tile + colored glyph (e.g. the Settings 2FA row: success.light tile
+ success.main glyph — low contrast, the reported "badly visible" icon),
and the solid-tile ones used the bright .main fill in dark mode without
darkening, so a white glyph on bright green/amber was low-contrast too
(DashProfile's success badge was ~1.9:1).

Add a single `iconBadgeSx(color)` helper (theme.ts): solid `<color>.main`
fill + white glyph, darkened in dark mode (FILLED_DARK_BG) so white stays
legible; `default` = neutral grey. Applied to every badge so they can't
drift: StatCard, DashActivityFeed, DashProfile (2FA badge), Dashboard
(2FA banner icon), AttendanceHistory (month tile), and the Settings 2FA
row tile (now solid green when required / grey when optional + white lock,
instead of the low-contrast light-green tile). Also switched DashProfile's
backup-codes warning callout from warning.light to a subtle warning wash
so its amber text stays readable in dark mode.

tsc -b --noEmit, npm run build, vitest 152/152 all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:34:06 +02:00

255 lines
9.0 KiB
TypeScript

import { createTheme, type Theme } from "@mui/material/styles";
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
const FONT_HEADING = "'Urbanist', sans-serif";
const FONT_MONO = "'DM Mono', Menlo, monospace";
// Standard Material easing (matches the legacy --transition cubic-bezier).
const EASE = "cubic-bezier(0.4, 0, 0.2, 1)";
// Dark-mode fills for FILLED colored surfaces (contained buttons, filled chips,
// StatCard badges). The palette .main stays BRIGHT in dark mode (it drives text,
// outlined buttons and row tints), but a bright fill makes WHITE text/glyphs
// illegible — so filled surfaces drop to these darker shades (≈ the light-scheme
// values) in dark mode. One rule everywhere: filled colored = white text,
// readable in both themes. Light mode needs no override (its .main is dark).
export const FILLED_DARK_BG = {
error: { bg: "#dc2626", hover: "#b91c1c" },
success: { bg: "#15803d", hover: "#166534" },
warning: { bg: "#b45309", hover: "#92400e" },
info: { bg: "#1d4ed8", hover: "#1e40af" },
} as const;
export const theme = createTheme({
cssVariables: {
colorSchemeSelector: "[data-theme='%s']",
},
colorSchemes: {
light: {
palette: {
mode: "light",
primary: { main: "#c73030" },
success: { main: "#15803d", contrastText: "#fff" },
warning: { main: "#b45309", contrastText: "#fff" },
error: { main: "#b91c1c", contrastText: "#fff" },
info: { main: "#1d4ed8", contrastText: "#fff" },
background: { default: "#f4f3f1", paper: "#ffffff" },
text: { primary: "#1a1a1a", secondary: "#555555" },
divider: "rgba(0,0,0,0.1)",
},
},
dark: {
palette: {
mode: "dark",
primary: { main: "#d63031" },
success: { main: "#22c55e", contrastText: "#1a1a1a" },
warning: { main: "#f59e0b", contrastText: "#1a1a1a" },
error: { main: "#ef4444", contrastText: "#1a1a1a" },
info: { main: "#3b82f6", contrastText: "#1a1a1a" },
background: { default: "#0f0f0f", paper: "#1a1a1a" },
text: { primary: "#ffffff", secondary: "#a0a0a0" },
divider: "rgba(255,255,255,0.08)",
},
},
},
shape: { borderRadius: 10 },
typography: {
fontFamily: FONT_BODY,
h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
h4: { fontFamily: FONT_HEADING, fontWeight: 700 },
h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
button: { textTransform: "none", fontWeight: 600 },
},
components: {
MuiButton: {
defaultProps: { disableElevation: true },
styleOverrides: {
root: {
borderRadius: 999,
paddingInline: "0.95rem",
// Snappy press (150ms) + smooth color/shadow (250ms).
transition: [
`background-color 250ms ${EASE}`,
`border-color 250ms ${EASE}`,
`color 200ms ${EASE}`,
`box-shadow 250ms ${EASE}`,
`transform 150ms ${EASE}`,
`filter 200ms ${EASE}`,
].join(", "),
"&:active": { transform: "scale(0.97)" },
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
},
containedPrimary: {
backgroundImage: "linear-gradient(135deg, #e23a3a, #c01f1f)",
boxShadow: "0 5px 14px rgba(214,48,49,0.32)",
// The gradient can't transition, so animate lift + glow instead.
"&:hover": {
transform: "translateY(-1px)",
filter: "brightness(1.04)",
boxShadow: "0 8px 20px rgba(214,48,49,0.42)",
},
"&:active": { transform: "translateY(0) scale(0.97)" },
},
// Filled colored buttons: WHITE text in BOTH themes (was per-scheme
// contrastText → near-black text on colored fills in dark mode, which
// also clashed with the always-white primary — "black text on one red
// button, white on another"). In dark mode the fill drops to a darker
// shade so white stays legible.
containedError: ({ theme }) => ({
color: "#fff",
...theme.applyStyles("dark", {
"&:not(.Mui-disabled)": {
backgroundColor: FILLED_DARK_BG.error.bg,
"&:hover": { backgroundColor: FILLED_DARK_BG.error.hover },
},
}),
}),
containedSuccess: ({ theme }) => ({
color: "#fff",
...theme.applyStyles("dark", {
"&:not(.Mui-disabled)": {
backgroundColor: FILLED_DARK_BG.success.bg,
"&:hover": { backgroundColor: FILLED_DARK_BG.success.hover },
},
}),
}),
containedWarning: ({ theme }) => ({
color: "#fff",
...theme.applyStyles("dark", {
"&:not(.Mui-disabled)": {
backgroundColor: FILLED_DARK_BG.warning.bg,
"&:hover": { backgroundColor: FILLED_DARK_BG.warning.hover },
},
}),
}),
containedInfo: ({ theme }) => ({
color: "#fff",
...theme.applyStyles("dark", {
"&:not(.Mui-disabled)": {
backgroundColor: FILLED_DARK_BG.info.bg,
"&:hover": { backgroundColor: FILLED_DARK_BG.info.hover },
},
}),
}),
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 16,
boxShadow:
"0 6px 20px rgba(20,20,40,0.06), 0 1px 2px rgba(0,0,0,0.03)",
transition: `box-shadow 250ms ${EASE}, transform 250ms ${EASE}`,
},
},
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: 999,
fontWeight: 700,
transition: `background-color 200ms ${EASE}, box-shadow 200ms ${EASE}`,
},
// Filled colored chips follow the same rule as filled buttons: white
// label in both themes, darker fill in dark mode so white stays legible.
// (Chip has no per-color `filledX` key, so target the color class and
// scope to the filled variant.)
colorError: ({ theme }) => ({
"&.MuiChip-filled": {
color: "#fff",
...theme.applyStyles("dark", {
backgroundColor: FILLED_DARK_BG.error.bg,
"&.MuiChip-clickable:hover": {
backgroundColor: FILLED_DARK_BG.error.hover,
},
}),
},
}),
colorSuccess: ({ theme }) => ({
"&.MuiChip-filled": {
color: "#fff",
...theme.applyStyles("dark", {
backgroundColor: FILLED_DARK_BG.success.bg,
"&.MuiChip-clickable:hover": {
backgroundColor: FILLED_DARK_BG.success.hover,
},
}),
},
}),
colorWarning: ({ theme }) => ({
"&.MuiChip-filled": {
color: "#fff",
...theme.applyStyles("dark", {
backgroundColor: FILLED_DARK_BG.warning.bg,
"&.MuiChip-clickable:hover": {
backgroundColor: FILLED_DARK_BG.warning.hover,
},
}),
},
}),
colorInfo: ({ theme }) => ({
"&.MuiChip-filled": {
color: "#fff",
...theme.applyStyles("dark", {
backgroundColor: FILLED_DARK_BG.info.bg,
"&.MuiChip-clickable:hover": {
backgroundColor: FILLED_DARK_BG.info.hover,
},
}),
},
}),
},
},
MuiOutlinedInput: {
styleOverrides: {
root: {
// Soft brand focus ring fades in over 200ms.
transition: `box-shadow 200ms ${EASE}`,
"&.Mui-focused": {
boxShadow: "0 0 0 3px rgba(199, 48, 48, 0.12)",
},
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
},
notchedOutline: {
transition: `border-color 200ms ${EASE}`,
},
},
},
},
});
export const fonts = {
body: FONT_BODY,
heading: FONT_HEADING,
mono: FONT_MONO,
};
export type IconBadgeColor =
| "default"
| "primary"
| "success"
| "warning"
| "error"
| "info";
/**
* sx fragment for a solid icon-badge tile: a `<color>.main` fill with a WHITE
* glyph, darkened in dark mode (FILLED_DARK_BG) so white stays legible on the
* otherwise-bright success/warning/info/error fills. `default` = neutral grey.
* Single source of truth for every labelled icon badge — use in an sx array
* next to the tile's size/shape so they can never drift apart:
* sx={[{ width: 40, height: 40, borderRadius: 2, display: "flex", … }, iconBadgeSx(color)]}
*/
export function iconBadgeSx(color: IconBadgeColor) {
const bg = color === "default" ? "grey.600" : `${color}.main`;
const darkBg = (FILLED_DARK_BG as Record<string, { bg: string }>)[color]?.bg;
return (theme: Theme) => ({
bgcolor: bg,
color: "common.white",
...(darkBg ? theme.applyStyles("dark", { backgroundColor: darkBg }) : {}),
});
}