feat(ui): establish semantic colors and focus foundation
This commit is contained in:
+77
-18
@@ -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,
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user