Files
jobtrackingapp/job-tracker-ui/src/theme.ts
T
cesnimda dab39bd570
CI and Deploy / test (push) Successful in 2m12s
CI and Deploy / deploy (push) Successful in 20s
feat(ui): add reduced-motion floor and tasteful hover micro-interactions
Global @media (prefers-reduced-motion: reduce) rule in MuiCssBaseline
collapses every animation/transition duration to near-zero at once --
covers MUI's own Dialog/Menu/Collapse/ripple transitions and every
hover-lift added below, so accessibility doesn't need re-checking
per-component as more motion gets added later.

Dashboard stat tiles and Kanban cards get a subtle hover lift (shadow
deepens, translateY(-2px)/-1px), matching the hover pattern the
landing page's feature cards already had -- these are the two places
a small lift reads as an affordance rather than noise: stat tiles are
dashboard-customization-adjacent, and kanban cards are literally
draggable, so the lift reinforces the existing grab-cursor signal
instead of competing with it. Left everything else alone -- most
cards on this app hold passive content, not something to invite a
hover response.
2026-07-13 15:07:11 +02:00

440 lines
14 KiB
TypeScript

import { alpha, createTheme, darken, lighten } from "@mui/material/styles";
type PaletteLike = Record<string, any>;
// Single global brand accent -- matches the dark sidebar/landing page indigo used throughout
// the app. Not user-configurable; see jobbjakt-nextjs-migration memory / UI rework notes.
const ACCENT = "#6366F1";
function buildPrimary(main: string) {
return {
lighter: lighten(main, 0.82),
light: lighten(main, 0.56),
main,
dark: darken(main, 0.22),
darker: darken(main, 0.72),
};
}
function buildLightPalette(): PaletteLike {
const textPrimary = "#1B1B1F";
const textSecondary = "#46464F";
const secondaryMain = "#5A5C78";
const divider = "#EFEDF4";
const background = "#FFFFFF";
const disabled = "#777680";
const disabledBackground = "#E4E1E6";
return {
primary: buildPrimary(ACCENT),
secondary: {
lighter: "#E0E0FF",
light: "#C3C4E4",
main: secondaryMain,
dark: "#43455F",
darker: "#171A31",
},
error: {
lighter: "#FFEDEA",
light: "#FFDAD6",
main: "#DE3730",
dark: "#BA1A1A",
darker: "#690005",
},
warning: {
lighter: "#FFEEE1",
light: "#FFDCBE",
main: "#AE6600",
dark: "#8B5000",
darker: "#4A2800",
},
success: {
lighter: "#DCFCE7",
light: "#BBF7D0",
main: "#16A34A",
dark: "#15803D",
darker: "#14532D",
},
info: {
lighter: "#D4F7FF",
light: "#A1EFFF",
main: "#008394",
dark: "#006876",
darker: "#00363E",
},
grey: {
50: "#FBF8FF",
100: "#F5F2FA",
200: divider,
300: "#EAE7EF",
400: disabledBackground,
500: "#DBD9E0",
600: "#C7C5D0",
700: disabled,
800: textSecondary,
900: textPrimary,
},
text: {
primary: textPrimary,
secondary: textSecondary,
disabled,
},
divider,
// Soft grey app background with white paper gives the layered dashboard look
// from the product mockups; cards/inputs (paper) sit above it.
background: { default: "#F4F6FB", paper: background },
action: {
hover: alpha(ACCENT, 0.05),
disabled: alpha(disabled, 0.6),
disabledBackground: alpha(disabledBackground, 0.9),
},
};
}
function buildDarkPalette(): PaletteLike {
const bg = "#0B0B0E";
const paper = "#111116";
const divider = alpha("#FFFFFF", 0.10);
const textPrimary = "#EAEAF2";
const textSecondary = alpha(textPrimary, 0.72);
const secondaryMain = "#A4A6C8";
const disabled = alpha(textPrimary, 0.5);
const disabledBackground = alpha("#FFFFFF", 0.08);
return {
primary: buildPrimary(ACCENT),
secondary: {
lighter: alpha(secondaryMain, 0.22),
light: alpha(secondaryMain, 0.14),
main: secondaryMain,
dark: alpha(secondaryMain, 0.8),
darker: alpha(secondaryMain, 0.95),
},
error: {
lighter: alpha("#DE3730", 0.18),
light: alpha("#DE3730", 0.12),
main: "#FF5A52",
dark: "#FF3B33",
darker: "#FFC2BE",
},
warning: {
lighter: alpha("#AE6600", 0.18),
light: alpha("#AE6600", 0.12),
main: "#FFB14A",
dark: "#FF9A1F",
darker: "#FFE1B8",
},
success: {
lighter: alpha("#16A34A", 0.18),
light: alpha("#16A34A", 0.12),
main: "#4ADE80",
dark: "#22C55E",
darker: "#BBF7D0",
},
info: {
lighter: alpha("#008394", 0.18),
light: alpha("#008394", 0.12),
main: "#22D3EE",
dark: "#06B6D4",
darker: "#BAF3FF",
},
grey: {
50: bg,
100: "#0F0F13",
200: "#15151B",
300: "#1C1C24",
400: "#23232D",
500: "#2D2D3A",
600: "#3A3A4A",
700: "#4A4A5D",
800: "#B8B8C6",
900: textPrimary,
},
text: {
primary: textPrimary,
secondary: textSecondary,
disabled,
},
divider,
background: { default: bg, paper },
action: {
hover: alpha(ACCENT, 0.16),
disabled: alpha("#FFFFFF", 0.5),
disabledBackground,
},
};
}
function buildCustomShadows(palette: any) {
const shadowColor = palette.text.primary;
const primaryColor = palette.primary.main;
return {
button: `0px 0.711px 1.422px 0px ${alpha(shadowColor, 0.05)}`,
// Mockup cards float on the grey page background with no visible border, just a soft,
// fairly generous shadow (dashboard stat tiles, pipeline columns' cards, job-workspace
// panels) -- the old 1px "section" shadow read as flat/bordered rather than elevated.
section: `0px 1px 2px 0px ${alpha(shadowColor, 0.04)}, 0px 8px 24px -12px ${alpha(shadowColor, 0.12)}`,
tooltip: `0px 12px 16px -4px ${alpha(shadowColor, 0.08)}, 0px 4px 6px -2px ${alpha(shadowColor, 0.03)}`,
focus: `0px 0px 0px 3px ${alpha(primaryColor, 0.2)}`,
// Gradient CTA (mockup's "Tailor my CV" / "See the interface tour" buttons) gets its own
// colored glow instead of the flat neutral button shadow.
gradientButton: `0px 4px 14px -4px ${alpha(primaryColor, 0.45)}`,
};
}
// Mockup's signature AI/primary-action gradient (dashboard "Tailor my CV", landing "See the
// interface tour"). Exported so every gradient CTA across the app stays pixel-identical
// instead of each screen inventing its own two-color mix -- consumed via <GradientButton>
// (src/components/GradientButton.tsx), not applied as a MUI Button variant (MUI's variant
// override machinery needs TS module augmentation for a genuinely new variant name; a plain
// wrapper component is far less code for the same result).
export const GRADIENT_CTA = "linear-gradient(90deg, #6366F1 0%, #22D3EE 100%)";
function buildTypography() {
return {
fontFamily: "'Archivo', system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif",
letterSpacing: 0,
// Mockup headings (F:\Pictures\website\jobtracker\new) are bold/black-weight display
// type ("Dashboard", "One workspace for the whole search") -- 500 across the board read
// as noticeably lighter/flatter than that reference. h1-h4 (page titles, hero, section
// headers) go heavy; h5/h6 (card titles, in-context labels) stay a lighter semibold so
// dense screens don't turn into a wall of black text.
h1: { fontWeight: 800, fontSize: 36, lineHeight: "40px", letterSpacing: "-0.02em" },
h2: { fontWeight: 800, fontSize: 30, lineHeight: "34px", letterSpacing: "-0.02em" },
h3: { fontWeight: 800, fontSize: 26, lineHeight: "30px", letterSpacing: "-0.01em" },
h4: { fontWeight: 700, fontSize: 22, lineHeight: "26px", letterSpacing: "-0.01em" },
h5: { fontWeight: 700, fontSize: 18, lineHeight: "22px" },
h6: { fontWeight: 600, fontSize: 16, lineHeight: "20px" },
subtitle1: { fontWeight: 600, fontSize: 14, lineHeight: "18px" },
subtitle2: { fontWeight: 600, fontSize: 13, lineHeight: "17px" },
body1: { fontWeight: 400, fontSize: 14, lineHeight: "18px" },
body2: { fontWeight: 400, fontSize: 13, lineHeight: "17px" },
caption: { fontWeight: 400, fontSize: 12, lineHeight: "16px", letterSpacing: 0 },
overline: { fontWeight: 700, fontSize: 11, lineHeight: "14px", letterSpacing: "0.08em", textTransform: "uppercase" as const },
caption1: { fontWeight: 500, fontSize: 12, lineHeight: "16px", letterSpacing: 0 },
button: { textTransform: "capitalize" as const, fontWeight: 600 },
};
}
export const getTheme = (_mode: "light" | "dark") => {
const lightPalette = buildLightPalette();
const darkPalette = buildDarkPalette();
const theme = createTheme({
breakpoints: {
values: {
xs: 0,
sm: 768,
md: 1024,
lg: 1266,
xl: 1440,
},
},
cssVariables: {
cssVarPrefix: "",
colorSchemeSelector: "data-color-scheme",
},
colorSchemes: {
light: { palette: lightPalette, customShadows: buildCustomShadows(lightPalette) },
dark: { palette: darkPalette, customShadows: buildCustomShadows(darkPalette) },
},
// Mockup radius reads noticeably rounder than the previous 10/8px defaults across cards,
// stat tiles, kanban cards and buttons.
shape: { borderRadius: 14 },
typography: buildTypography() as any,
} as any) as any;
theme.components = {
MuiCssBaseline: {
styleOverrides: {
body: {
WebkitFontSmoothing: "antialiased",
MozOsxFontSmoothing: "grayscale",
},
// A global floor under every animation added this session (card hover-lift,
// MUI's own Dialog/Menu/Collapse transitions, ripple) -- users who set this OS/
// browser preference get near-instant, non-animated interactions everywhere at
// once, rather than each component needing its own reduced-motion check.
"@media (prefers-reduced-motion: reduce)": {
"*, *::before, *::after": {
animationDuration: "0.01ms !important",
animationIterationCount: "1 !important",
transitionDuration: "0.01ms !important",
scrollBehavior: "auto !important",
},
},
},
},
MuiPaper: {
defaultProps: { elevation: 0 },
styleOverrides: {
root: ({ theme }: any) => ({
border: `1px solid ${theme.vars.palette.divider}`,
boxShadow: "none",
backgroundImage: "none",
}),
},
},
MuiCard: {
defaultProps: { elevation: 0 },
styleOverrides: {
root: ({ theme }: any) => ({
// No border -- mockup cards float on the grey page background purely via shadow.
border: "none",
borderRadius: 16,
backgroundImage: "none",
boxShadow: theme.vars.customShadows.section,
}),
},
},
// Every Dialog/Modal renders its content via MuiPaper, which is deliberately flat
// (border, no shadow -- see MuiPaper above, kept minimal since it's used too broadly
// to safely restyle globally). A dialog is inherently a floating overlay though, so it
// should never be flat -- this override wins on specificity without touching MuiPaper's
// own defaults, giving every modal in the app (confirm/prompt, AddJobModal,
// EditJobDialog, JobDetailsDialog, etc.) the same rounded floating-shadow treatment in
// one place instead of patching each dialog's own sx individually.
MuiDialog: {
styleOverrides: {
paper: ({ theme }: any) => ({
border: "none",
borderRadius: 20,
boxShadow: theme.vars.customShadows.tooltip,
}),
},
},
MuiPopover: {
styleOverrides: {
paper: ({ theme }: any) => ({
border: "none",
boxShadow: theme.vars.customShadows.tooltip,
}),
},
},
MuiMenu: {
styleOverrides: {
paper: ({ theme }: any) => ({
border: "none",
borderRadius: 12,
boxShadow: theme.vars.customShadows.tooltip,
}),
},
},
MuiButton: {
defaultProps: { disableFocusRipple: true },
styleOverrides: {
root: ({ theme }: any) => ({
borderRadius: 10,
boxShadow: theme.vars.customShadows.button,
"&:hover": { boxShadow: theme.vars.customShadows.button },
}),
},
},
MuiTextField: {
defaultProps: {
size: "small",
variant: "outlined",
},
},
MuiOutlinedInput: {
defaultProps: { size: "small" },
styleOverrides: {
root: ({ theme }: any) => ({
borderRadius: 8,
boxShadow: theme.vars.customShadows.button,
background: theme.vars.palette.background.default,
paddingLeft: 10,
paddingRight: 10,
minHeight: 42,
display: "flex",
alignItems: "center",
"&.Mui-disabled": {
cursor: "not-allowed",
input: { cursor: "not-allowed" },
"& .MuiOutlinedInput-notchedOutline": { borderColor: theme.vars.palette.divider },
},
}),
notchedOutline: ({ theme }: any) => ({ borderColor: theme.vars.palette.divider }),
multiline: {
padding: 10,
alignItems: "flex-start",
minHeight: "unset",
},
input: {
paddingLeft: 0,
paddingRight: 0,
paddingTop: 9,
paddingBottom: 9,
lineHeight: 1.45,
display: "flex",
alignItems: "center",
},
inputMultiline: {
paddingTop: 0,
paddingBottom: 0,
lineHeight: 1.5,
},
},
},
MuiInputBase: {
styleOverrides: {
input: {
"&::placeholder": {
opacity: 0.72,
},
},
inputMultiline: {
"&::placeholder": {
opacity: 0.72,
},
},
},
},
MuiTooltip: {
styleOverrides: {
tooltip: {
borderRadius: 8,
fontWeight: 600,
fontSize: 12,
},
},
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: 999,
fontWeight: 600,
},
},
},
MuiListItemButton: {
styleOverrides: {
root: ({ theme }: any) => ({
borderRadius: 8,
"&.Mui-selected": {
backgroundColor: theme.vars.palette.action.hover,
},
}),
},
},
MuiTableCell: {
styleOverrides: {
head: ({ theme }: any) => ({
fontWeight: 600,
color: theme.vars.palette.text.primary,
background: theme.vars.palette.grey[100],
}),
root: {
paddingTop: 10,
paddingBottom: 10,
},
},
},
} as any;
return theme;
};