Sidebar (user-approved design, docs/superpowers/specs/2026-06-12-sidebar- icons-design.md): all 31 menu items get unique contextual stroke glyphs in rounded-square tiles tinted per section (Docházka blue, Kniha jízd teal, Administrativa amber, Sklad violet, Systém slate; new teal/violet/slate palette entries with channel tokens in both schemes). Replaces the old set where 13 items shared a glyph (3x people, 3x sliders, ...) and Faktury was a dollar sign. Odin stays the only solid + animated tile. Uniqueness pinned by navdata-icons.test.ts (tsconfig.test.json gains jsx for the .tsx import). Headers: ProjectDetail and OrderDetail now match the Offer/Invoice shell — number rendered in secondary color at regular weight next to the bold entity word, and flexWrap on both left header boxes so the title drops below Zpět on phones instead of overflowing the viewport (the reported mobile bug on received orders). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
import {
|
|
createTheme,
|
|
type Theme,
|
|
type PaletteColor,
|
|
type PaletteColorChannel,
|
|
} 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" },
|
|
// Sidebar section hues (full objects so cssVariables emits *Channel
|
|
// tokens — the nav tiles use channel-alpha washes).
|
|
teal: {
|
|
main: "#0e8a7c",
|
|
light: "#3aa99c",
|
|
dark: "#0a675d",
|
|
contrastText: "#fff",
|
|
},
|
|
violet: {
|
|
main: "#6a4cb4",
|
|
light: "#8a6fd0",
|
|
dark: "#54399a",
|
|
contrastText: "#fff",
|
|
},
|
|
slate: {
|
|
main: "#5c6470",
|
|
light: "#7d8694",
|
|
dark: "#454c56",
|
|
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" },
|
|
// Sidebar section hues — brighter mains so the glyphs read on dark.
|
|
teal: {
|
|
main: "#2dd4bf",
|
|
light: "#5eead4",
|
|
dark: "#14b8a6",
|
|
contrastText: "#1a1a1a",
|
|
},
|
|
violet: {
|
|
main: "#a78bfa",
|
|
light: "#c4b5fd",
|
|
dark: "#8b5cf6",
|
|
contrastText: "#1a1a1a",
|
|
},
|
|
slate: {
|
|
main: "#94a3b8",
|
|
light: "#cbd5e1",
|
|
dark: "#64748b",
|
|
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,
|
|
// Page/detail headlines: MUI's default 2.125rem overflows phone
|
|
// viewports (long document titles + number). Scale down on xs only.
|
|
"@media (max-width:600px)": { fontSize: "1.5rem" },
|
|
},
|
|
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)" },
|
|
// Honor reduced-motion like MuiButton.root / MuiOutlinedInput: drop
|
|
// the lift/press transform (glow + brightness stay, they don't move).
|
|
"@media (prefers-reduced-motion: reduce)": {
|
|
"&:hover": { transform: "none" },
|
|
"&:active": { transform: "none" },
|
|
},
|
|
},
|
|
// 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,
|
|
};
|
|
|
|
// Custom sidebar-section palette entries (teal/violet/slate above). The
|
|
// channel intersection matters: theme.vars.palette.<hue>.mainChannel is how
|
|
// the nav tiles build their alpha washes.
|
|
type NavPaletteColor = PaletteColor & PaletteColorChannel;
|
|
|
|
declare module "@mui/material/styles" {
|
|
interface Palette {
|
|
teal: NavPaletteColor;
|
|
violet: NavPaletteColor;
|
|
slate: NavPaletteColor;
|
|
}
|
|
interface PaletteOptions {
|
|
teal?: PaletteOptions["primary"];
|
|
violet?: PaletteOptions["primary"];
|
|
slate?: PaletteOptions["primary"];
|
|
}
|
|
}
|
|
|
|
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 }) : {}),
|
|
});
|
|
}
|