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
+10 -11
View File
@@ -66,15 +66,14 @@ export function activeNavTo(pathname: string, tos: string[]): string | null {
return best;
}
// The nav rail stays a fixed dark navy regardless of the app's light/dark theme toggle --
// a deliberate signature element, not derived from theme tokens.
const SIDEBAR_BG = "#0f172a";
const SIDEBAR_BORDER = "rgba(255,255,255,0.08)";
const SIDEBAR_TEXT_MUTED = "#94a3b8";
const SIDEBAR_TEXT = "#e2e8f0";
const SIDEBAR_SELECTED_BG = "rgba(99,102,241,0.18)";
const SIDEBAR_SELECTED_TEXT = "#ffffff";
const SIDEBAR_SELECTED_ICON = "#a5b4fc";
// Preserve the existing inverse navigation surface in both themes.
const SIDEBAR_BG = "var(--palette-navigation-background)";
const SIDEBAR_BORDER = "var(--palette-navigation-border)";
const SIDEBAR_TEXT_MUTED = "var(--palette-navigation-muted)";
const SIDEBAR_TEXT = "var(--palette-navigation-text)";
const SIDEBAR_SELECTED_BG = "var(--palette-navigation-selected)";
const SIDEBAR_SELECTED_TEXT = "var(--palette-navigation-selectedText)";
const SIDEBAR_SELECTED_ICON = "var(--palette-navigation-selectedIcon)";
export default function AppShell({
pageTitle,
@@ -181,7 +180,7 @@ export default function AppShell({
justifyContent: desktopNavCollapsed ? "center" : "flex-start",
border: "1px solid transparent",
color: SIDEBAR_TEXT_MUTED,
"&:hover": { backgroundColor: "rgba(255,255,255,0.06)", color: SIDEBAR_TEXT },
"&:hover": { backgroundColor: "var(--palette-navigation-hover)", color: SIDEBAR_TEXT },
"&.Mui-selected": {
backgroundColor: SIDEBAR_SELECTED_BG,
color: SIDEBAR_SELECTED_TEXT,
@@ -210,7 +209,7 @@ export default function AppShell({
);
const drawerContent = (
<Box sx={{ height: "100%", display: "flex", flexDirection: "column", backgroundColor: SIDEBAR_BG }}>
<Box sx={{ "--app-focus-color": "var(--palette-focus-inverse)", height: "100%", display: "flex", flexDirection: "column", backgroundColor: SIDEBAR_BG }}>
<Box sx={{ px: desktopNavCollapsed ? 1.5 : 2.25, py: desktopNavCollapsed ? 2 : 2.5, display: "flex", justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, justifyContent: desktopNavCollapsed ? "center" : "flex-start" }}>
<JobbjaktMark style={{ width: 30, height: 30, flexShrink: 0 }} />
@@ -0,0 +1,54 @@
import React from "react";
import "@testing-library/jest-dom";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { api } from "./api";
import CompaniesTable from "./components/CompaniesTable";
import { I18nProvider } from "./i18n/I18nProvider";
import JobTrackerThemeProvider from "./ThemeProvider";
import { ToastProvider } from "./toast";
jest.mock("./api", () => ({
api: { get: jest.fn(), put: jest.fn() },
getApiErrorMessage: (_error: unknown, fallback: string) => fallback,
}));
describe.each(["light", "dark"] as const)("%s loaded company workflow", mode => {
beforeEach(() => { localStorage.clear(); localStorage.setItem("uiLanguage", "en"); jest.clearAllMocks(); });
test.each(["populated", "empty", "error"])("loading → %s remains understandable and usable", async outcome => {
let finish!: (value: unknown) => void;
let fail!: (error: unknown) => void;
(api.get as jest.Mock).mockReturnValue(new Promise((resolve, reject) => { finish = resolve; fail = reject; }));
render(<JobTrackerThemeProvider preference={mode} systemPrefersDark={false}>
<I18nProvider><ToastProvider><MemoryRouter><CompaniesTable /></MemoryRouter></ToastProvider></I18nProvider>
</JobTrackerThemeProvider>);
expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
expect(screen.getByRole("progressbar")).toHaveAccessibleName();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
const company = { id: 3, name: "Nordlys æøå AS" };
await act(async () => {
if (outcome === "error") fail({ response: { status: 503 } });
else finish({ data: outcome === "empty" ? [] : [company] });
});
expect(screen.queryByRole("status")).not.toBeInTheDocument();
if (outcome === "error") {
const error = screen.getByRole("alert");
expect(error).not.toBeEmptyDOMElement();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
(api.get as jest.Mock).mockResolvedValue({ data: [company] });
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText(company.name)).toBeVisible();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
}
expect(screen.getByRole("table")).toBeVisible();
if (outcome !== "empty") {
fireEvent.click(screen.getByRole("button", { name: `Edit: ${company.name}` }));
expect(screen.getByRole("dialog")).toHaveAccessibleName();
// Reading/editing a loaded form must not repeat the loading announcement or save.
expect(screen.queryByRole("status")).not.toBeInTheDocument();
} else {
expect(screen.getByRole("table")).toHaveTextContent("No companies");
}
expect(api.put).not.toHaveBeenCalled();
});
});
+24
View File
@@ -0,0 +1,24 @@
import { getContrastRatio } from "@mui/material/styles";
import { getTheme } from "./theme";
// Contract tests for text/button pairs, not snapshots of particular brand hex values.
describe.each(["light", "dark"] as const)("%s semantic contrast", mode => {
const palette = getTheme(mode).colorSchemes[mode].palette;
test.each(["primary", "secondary", "success", "warning", "error", "info"])("%s supports text and filled actions", role => {
const color = palette[role];
for (const surface of [palette.background.default, palette.background.paper, palette.background.elevated ?? palette.background.paper]) {
expect(getContrastRatio(color.main, surface)).toBeGreaterThanOrEqual(4.5);
}
for (const fill of [color.main, color.dark]) {
expect(getContrastRatio(color.contrastText, fill)).toBeGreaterThanOrEqual(4.5);
}
});
test("focus and essential control boundaries contrast with all application surfaces", () => {
for (const surface of [palette.background.default, palette.background.paper, palette.background.elevated]) {
expect(getContrastRatio(palette.focus.main, surface)).toBeGreaterThanOrEqual(3);
expect(getContrastRatio(palette.border.default, surface)).toBeGreaterThanOrEqual(3);
}
expect(getContrastRatio(palette.focus.inverse, palette.navigation.background)).toBeGreaterThanOrEqual(3);
expect(palette.action.selected).not.toBe(palette.action.hover);
});
});
+18
View File
@@ -0,0 +1,18 @@
import "@mui/material/styles";
declare module "@mui/material/styles" {
interface TypeBackground { elevated: string }
interface Palette {
border: { subtle: string; default: string; strong: string };
focus: { main: string; inverse: string };
navigation: {
background: string; border: string; text: string; muted: string; hover: string;
selected: string; selectedText: string; selectedIcon: string;
};
}
interface PaletteOptions {
border?: Palette["border"];
focus?: Palette["focus"];
navigation?: Palette["navigation"];
}
}
+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,
},
}),
},
+4 -2
View File
@@ -942,7 +942,9 @@ function SectionRow({
variant="outlined"
{...dragProps}
sx={{
opacity: dragging ? 0.4 : row.hidden ? 0.58 : 1,
// Hidden in the document is not disabled in the editor: keep names and
// controls readable. The visibility icon/label still conveys inclusion.
opacity: dragging ? 0.4 : 1,
borderTop: over ? "2px solid" : undefined, borderTopColor: over ? "primary.main" : undefined,
transition: "opacity 150ms ease, border-color 120ms ease, box-shadow 150ms ease",
overflow: "hidden", "&:hover": { boxShadow: 1 },
@@ -1062,7 +1064,7 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
return (
<Paper key={key} variant="outlined" {...drag.getItemProps(i)}
sx={{
p: 0.75, opacity: drag.dragIndex === i ? 0.4 : hidden ? 0.5 : 1, cursor: "grab",
p: 0.75, opacity: drag.dragIndex === i ? 0.4 : 1, cursor: "grab",
borderTop: drag.overIndex === i && drag.dragIndex !== i ? "2px solid" : undefined,
borderTopColor: "primary.main", transition: "opacity 150ms ease",
}}>