feat(ui): establish semantic colors and focus foundation

This commit is contained in:
cesnimda
2026-09-11 09:04:55 +02:00
parent f696f23fc5
commit 115441a409
26 changed files with 1689 additions and 34 deletions
+77 -18
View File
@@ -2,9 +2,18 @@ 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.
// Existing provisional brand hue. Dark mode uses a lighter tone for readable text;
// this does not settle the Phase 3 brand/identity decision.
const ACCENT = "#5B5BD6";
const DARK_ACCENT = lighten(ACCENT, 0.45);
// The existing inverse sidebar remains inverse in both modes. These are MUI palette
// roles so feature code does not have to invent colors for this surface.
const navigation = {
background: "#0F172A", border: "rgba(255,255,255,0.08)",
text: "#E2E8F0", muted: "#94A3B8", hover: "rgba(255,255,255,0.06)",
selected: "rgba(99,102,241,0.18)", selectedText: "#FFFFFF", selectedIcon: "#A5B4FC",
};
function buildPrimary(main: string) {
return {
@@ -28,7 +37,11 @@ function buildLightPalette(): PaletteLike {
const disabledBackground = "#E4E1E6";
return {
primary: buildPrimary(ACCENT),
primary: { ...buildPrimary(ACCENT), contrastText: "#FFFFFF" },
contrastThreshold: 4.5,
navigation,
focus: { main: ACCENT, inverse: "#A5B4FC" },
border: { subtle: divider, default: "#777680", strong: textSecondary },
secondary: {
lighter: "#E0E0FF",
light: "#C3C4E4",
@@ -39,29 +52,29 @@ function buildLightPalette(): PaletteLike {
error: {
lighter: "#FFEDEA",
light: "#FFDAD6",
main: "#DE3730",
dark: "#BA1A1A",
main: "#BA1A1A",
dark: darken("#BA1A1A", 0.16),
darker: "#690005",
},
warning: {
lighter: "#FFEEE1",
light: "#FFDCBE",
main: "#AE6600",
dark: "#8B5000",
main: "#8B5000",
dark: darken("#8B5000", 0.16),
darker: "#4A2800",
},
success: {
lighter: "#DCFCE7",
light: "#BBF7D0",
main: "#16A34A",
dark: "#15803D",
main: "#15803D",
dark: darken("#15803D", 0.16),
darker: "#14532D",
},
info: {
lighter: "#D4F7FF",
light: "#A1EFFF",
main: "#008394",
dark: "#006876",
main: "#006876",
dark: darken("#006876", 0.16),
darker: "#00363E",
},
grey: {
@@ -84,9 +97,11 @@ function buildLightPalette(): PaletteLike {
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 },
background: { default: "#F4F6FB", paper: background, elevated: background },
action: {
hover: alpha(ACCENT, 0.05),
selected: alpha(ACCENT, 0.12),
focus: alpha(ACCENT, 0.15),
disabled: alpha(disabled, 0.6),
disabledBackground: alpha(disabledBackground, 0.9),
},
@@ -105,7 +120,11 @@ function buildDarkPalette(): PaletteLike {
const disabledBackground = alpha("#FFFFFF", 0.08);
return {
primary: buildPrimary(ACCENT),
primary: { ...buildPrimary(DARK_ACCENT), contrastText: paper },
contrastThreshold: 4.5,
navigation,
focus: { main: DARK_ACCENT, inverse: "#A5B4FC" },
border: { subtle: divider, default: "#777680", strong: "#B8B8C6" },
secondary: {
lighter: alpha(secondaryMain, 0.22),
light: alpha(secondaryMain, 0.14),
@@ -159,9 +178,11 @@ function buildDarkPalette(): PaletteLike {
disabled,
},
divider,
background: { default: bg, paper },
background: { default: bg, paper, elevated: "#1C1C24" },
action: {
hover: alpha(ACCENT, 0.16),
hover: alpha(DARK_ACCENT, 0.10),
selected: alpha(DARK_ACCENT, 0.18),
focus: alpha(DARK_ACCENT, 0.20),
disabled: alpha("#FFFFFF", 0.5),
disabledBackground,
},
@@ -178,7 +199,7 @@ function buildCustomShadows(palette: any) {
// 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)}`,
focus: `0px 0px 0px 3px ${palette.focus.main}`,
// 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)}`,
@@ -248,6 +269,13 @@ export const getTheme = (_mode: "light" | "dark") => {
typography: buildTypography() as any,
} as any) as any;
// ButtonBase covers buttons/icons/tabs/menu items/interactive cards. Native links
// need the same rule. An outline survives forced colors and adds a shape, not
// just a color change. Inset variants stay visible inside scrolling tab/menu lists.
const focusRing = {
outline: `3px solid var(--app-focus-color, ${theme.vars.palette.focus.main})`,
outlineOffset: "2px",
};
theme.components = {
MuiCssBaseline: {
styleOverrides: {
@@ -270,6 +298,7 @@ export const getTheme = (_mode: "light" | "dark") => {
MozOsxFontSmoothing: "grayscale",
overflowX: "hidden",
},
"a:focus-visible, button:focus-visible, [tabindex]:focus-visible, input:not(.MuiInputBase-input):focus-visible, textarea:not(.MuiInputBase-input):focus-visible, select:focus-visible": focusRing,
// 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
@@ -284,6 +313,21 @@ export const getTheme = (_mode: "light" | "dark") => {
},
},
},
MuiButtonBase: {
styleOverrides: { root: { "&.Mui-focusVisible": focusRing } },
},
MuiLink: {
styleOverrides: { root: { "&:focus-visible": focusRing } },
},
MuiTab: {
styleOverrides: { root: { "&.Mui-focusVisible": { ...focusRing, outlineOffset: "-3px" } } },
},
MuiMenuItem: {
styleOverrides: { root: { "&.Mui-focusVisible": { ...focusRing, outlineOffset: "-3px" } } },
},
MuiCardActionArea: {
styleOverrides: { root: { "&.Mui-focusVisible": { ...focusRing, outlineOffset: "-3px" } } },
},
MuiPaper: {
defaultProps: { elevation: 0 },
styleOverrides: {
@@ -316,6 +360,7 @@ export const getTheme = (_mode: "light" | "dark") => {
MuiDialog: {
styleOverrides: {
paper: ({ theme }: any) => ({
backgroundColor: theme.vars.palette.background.elevated,
border: "none",
borderRadius: 20,
boxShadow: theme.vars.customShadows.tooltip,
@@ -340,6 +385,7 @@ export const getTheme = (_mode: "light" | "dark") => {
MuiPopover: {
styleOverrides: {
paper: ({ theme }: any) => ({
backgroundColor: theme.vars.palette.background.elevated,
border: "none",
boxShadow: theme.vars.customShadows.tooltip,
}),
@@ -382,13 +428,21 @@ export const getTheme = (_mode: "light" | "dark") => {
minHeight: 42,
display: "flex",
alignItems: "center",
// Use the existing label notch: an outer outline crosses floating labels.
"&:has(input:focus-visible, textarea:focus-visible, [role=combobox]:focus-visible)": {
outline: "none",
"& .MuiOutlinedInput-notchedOutline": {
borderWidth: "3px",
borderColor: `var(--app-focus-color, ${theme.vars.palette.focus.main})`,
},
},
"&.Mui-disabled": {
cursor: "not-allowed",
input: { cursor: "not-allowed" },
"& .MuiOutlinedInput-notchedOutline": { borderColor: theme.vars.palette.divider },
},
}),
notchedOutline: ({ theme }: any) => ({ borderColor: theme.vars.palette.divider }),
notchedOutline: ({ theme }: any) => ({ borderColor: theme.vars.palette.border.default }),
multiline: {
padding: 10,
alignItems: "flex-start",
@@ -412,6 +466,10 @@ export const getTheme = (_mode: "light" | "dark") => {
},
MuiInputBase: {
styleOverrides: {
root: {
"&:has(input:focus-visible, textarea:focus-visible, [role=combobox]:focus-visible)": focusRing,
"& input:focus-visible, & textarea:focus-visible, & [role=combobox]:focus-visible": { outline: "none" },
},
input: {
"&::placeholder": {
opacity: 0.72,
@@ -445,8 +503,9 @@ export const getTheme = (_mode: "light" | "dark") => {
styleOverrides: {
root: ({ theme }: any) => ({
borderRadius: 8,
"&.Mui-focusVisible": { ...focusRing, outlineOffset: "-3px" },
"&.Mui-selected": {
backgroundColor: theme.vars.palette.action.hover,
backgroundColor: theme.vars.palette.action.selected,
},
}),
},