From e69a2f05af46792c21c1e376ac1195e691375317 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 3 Sep 2026 00:45:40 -0400 Subject: [PATCH 01/15] feat(client): define the neutral palette and switch to Geist The palette declared a brand green and six chart hues but never defined the neutrals, so MUI's defaults filled the gap: body text at 60% black, dividers at 12%, and background.default equal to background.paper. That is the "washed out" everything sat on - table cells render through text.secondary, and cards had no ground to sit on. - Opaque neutral ramp, biased slightly green so it sits with the brand. background.default is now off-white so paper separates from the page. - Semantic success/error/warning derived from the brand instead of Material's stock red/green/orange. Only `main` is set: `light` is consumed as a saturated colour by status dots, histogram bars and heatmap cells, so MUI must keep deriving it. - Dark mode gets the same treatment; it defined background and secondary but its text and divider were still MUI defaults. - Geist Variable, self-hosted via @fontsource-variable so air-gapped installs keep the typeface. Base size stays at 13px. - h1 700 / h2 600 against 400 body, and table headers 500 -> 600. Two weights was why everything read at the same visual weight. All contrast pairs pass WCAG AA in both themes (lowest 5.98:1). No component markup changed. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/package-lock.json | 10 ++++ client/package.json | 1 + .../src/Components/design-elements/Table.tsx | 2 +- client/src/Utils/Theme/Palette.ts | 54 +++++++++++++++++++ client/src/Utils/Theme/Theme.ts | 8 +-- client/src/main.tsx | 2 + 6 files changed, 73 insertions(+), 4 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 6cee968e3..629678180 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,6 +11,7 @@ "@csstools/css-tokenizer": "^3.0.4", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", + "@fontsource-variable/geist": "^5.3.0", "@hello-pangea/dnd": "^18.0.0", "@hookform/resolvers": "5.2.2", "@mui/lab": "7.0.0", @@ -950,6 +951,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fontsource-variable/geist": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", + "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "license": "BSD-3-Clause" diff --git a/client/package.json b/client/package.json index ce2e481ba..a26499805 100644 --- a/client/package.json +++ b/client/package.json @@ -21,6 +21,7 @@ "@csstools/css-tokenizer": "^3.0.4", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", + "@fontsource-variable/geist": "^5.3.0", "@hello-pangea/dnd": "^18.0.0", "@hookform/resolvers": "5.2.2", "@mui/lab": "7.0.0", diff --git a/client/src/Components/design-elements/Table.tsx b/client/src/Components/design-elements/Table.tsx index df0d52b16..9ef7dcee8 100644 --- a/client/src/Components/design-elements/Table.tsx +++ b/client/src/Components/design-elements/Table.tsx @@ -197,7 +197,7 @@ export function DataTable< "& :is(th)": { backgroundColor: theme.palette.background.paper, color: theme.palette.text.secondary, - fontWeight: 500, + fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.08em", padding: `${theme.spacing(SPACING.LG)} ${theme.spacing(LAYOUT.MD)}`, diff --git a/client/src/Utils/Theme/Palette.ts b/client/src/Utils/Theme/Palette.ts index 0d9168f8a..bfb9dda5d 100644 --- a/client/src/Utils/Theme/Palette.ts +++ b/client/src/Utils/Theme/Palette.ts @@ -18,6 +18,23 @@ export const colors = { gray700: "#313131", gray900: "#151518", gray850: "#1c1c21", + // Neutral ramp, biased slightly green so it sits with the brand rather than + // against it. Opaque values - alpha-black over white reads washed out. + ink900: "#101917", + ink600: "#55635C", + ink400: "#7C8983", + surface50: "#F6F8F7", + rule200: "#D9E1DD", + // Semantic colours derived from the brand rather than Material's defaults + successMain: "#0D6049", + errorMain: "#A02B24", + warningMain: "#8C5806", + inkDark900: "#ECEFED", + inkDark600: "#A3ADA8", + ruleDark: "#2C3532", + successDark: "#4BC49E", + errorDark: "#E8837A", + warningDark: "#E0A458", brandGreen: "#13715B", brandGreenLight: "#4DAF94", green200: "#ECF7F2", @@ -47,6 +64,28 @@ export const lightPalette = { secondary: { main: colors.gray200, }, + background: { + default: colors.surface50, + paper: "#FFFFFF", + }, + text: { + primary: colors.ink900, + secondary: colors.ink600, + disabled: colors.ink400, + }, + divider: colors.rule200, + // Only `main` is set. MUI derives `light`/`dark` from it, and `light` is + // consumed as a saturated colour - status dots, histogram bars, heatmap + // cells - so it must not be overridden with a pale tint. + success: { + main: colors.successMain, + }, + error: { + main: colors.errorMain, + }, + warning: { + main: colors.warningMain, + }, sidebar: { accent: colors.brandGreen, }, @@ -81,6 +120,21 @@ export const darkPalette = { default: colors.gray900, paper: colors.gray850, }, + text: { + primary: colors.inkDark900, + secondary: colors.inkDark600, + disabled: colors.ink400, + }, + divider: colors.ruleDark, + success: { + main: colors.successDark, + }, + error: { + main: colors.errorDark, + }, + warning: { + main: colors.warningDark, + }, sidebar: { accent: colors.brandGreenLight, }, diff --git a/client/src/Utils/Theme/Theme.ts b/client/src/Utils/Theme/Theme.ts index c63929a60..a0b1d75be 100644 --- a/client/src/Utils/Theme/Theme.ts +++ b/client/src/Utils/Theme/Theme.ts @@ -57,7 +57,7 @@ export type PaletteKey = { [K in keyof Theme["palette"]]: Theme["palette"][K] extends { main: any } ? K : never; }[keyof Theme["palette"]]; -const fontFamilyPrimary = "system-ui, sans-serif"; +const fontFamilyPrimary = '"Geist Variable", system-ui, sans-serif'; const fontFamilyMonospace = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace'; const shadow = @@ -85,11 +85,13 @@ export const theme = (mode: string, palette: any) => fontSize: typographyLevels.base, h1: { fontSize: typographyLevels.xxl, - fontWeight: 500, + fontWeight: 700, + letterSpacing: "-0.015em", }, h2: { fontSize: typographyLevels.l, - fontWeight: 400, + fontWeight: 600, + letterSpacing: "-0.01em", }, body1: { fontSize: typographyLevels.m, diff --git a/client/src/main.tsx b/client/src/main.tsx index 1f5d4d2ef..ca81c338c 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,5 +1,7 @@ import ReactDOM from "react-dom/client"; import App from "./App.jsx"; +// Self-hosted so the app keeps its typeface on air-gapped installs +import "@fontsource-variable/geist"; import "./index.css"; import { BrowserRouter as Router } from "react-router-dom"; import { Provider } from "react-redux"; From bdad9491703fa2b3a76dd4277b9a738269b41822 Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 3 Sep 2026 00:48:02 -0400 Subject: [PATCH 02/15] fix(client): put the tab bar back on the 13px base Tabs.tsx hardcoded fontSize: 14, so the tab bar sat a pixel above the 13px base the rest of the chrome uses. Now typographyLevels.m, which also satisfies the no-hardcoded-literals rule. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/design-elements/Tabs.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/Components/design-elements/Tabs.tsx b/client/src/Components/design-elements/Tabs.tsx index 149c0bd93..ae9a1c512 100644 --- a/client/src/Components/design-elements/Tabs.tsx +++ b/client/src/Components/design-elements/Tabs.tsx @@ -1,6 +1,7 @@ import MuiTabs from "@mui/material/Tabs"; import type { TabsProps } from "@mui/material/Tabs"; import { useTheme } from "@mui/material/styles"; +import { typographyLevels } from "@/Utils/Theme/Palette"; interface CustomTabsProps extends TabsProps {} export const Tabs = (props: CustomTabsProps) => { @@ -38,7 +39,7 @@ export const Tab = (props: CustomTabProps) => { iconPosition="start" sx={{ textTransform: "none", - fontSize: 14, + fontSize: typographyLevels.m, fontWeight: 500, minHeight: 34, padding: theme.spacing(1, 0), From 1372e66749cd955e33e36364d3474e3d7c69535a Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 3 Sep 2026 00:52:41 -0400 Subject: [PATCH 03/15] fix(client): calm the stat box grid and firm up borders Three fixes, all fallout from giving `divider` a real value: - The graph-paper texture behind the status counts is drawn with `divider`. That used to be rgba(0,0,0,.12) and near-invisible; now it is opaque, so the grid jumped forward and read as the loudest thing on the page. It is texture, not a rule, so it now takes divider at 40%. - GradientBox called lighten() on background.paper to build its gradient. paper is white in light mode and lightening white is a no-op, so the gradient collapsed flat. Darkens slightly toward the page ground instead. - Borders were too faint at #D9E1DD (1.33:1 against paper). Now #C7D2CC (1.55:1), and dark mode #2C3532 -> #3A4642 (1.73:1), so tables and cards have a visible edge without turning into hard rules. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/design-elements/StatBox.tsx | 6 ++++-- client/src/Components/design-elements/StatusBox.tsx | 9 ++++++--- client/src/Utils/Theme/Palette.ts | 4 ++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/client/src/Components/design-elements/StatBox.tsx b/client/src/Components/design-elements/StatBox.tsx index 1543766fc..addbc7ee9 100644 --- a/client/src/Components/design-elements/StatBox.tsx +++ b/client/src/Components/design-elements/StatBox.tsx @@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import Box from "@mui/material/Box"; import { useTheme } from "@mui/material/styles"; -import { lighten } from "@mui/material/styles"; +import { darken, lighten } from "@mui/material/styles"; import useMediaQuery from "@mui/material/useMediaQuery"; import type { PaletteKey } from "@/Utils/Theme/Theme"; import { BaseBox, TooltipWithInfo } from "@/Components/design-elements"; @@ -19,7 +19,9 @@ export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isLight = theme.palette.mode === "light"; const paper = theme.palette.background.paper; - const paperStart = lighten(paper, isLight ? 0.06 : 0.08); + // In light mode `paper` is white, and lightening white is a no-op - the + // gradient collapses flat. Darken slightly towards the page ground instead. + const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08); const paperEnd = paper; const bg = palette ? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)` diff --git a/client/src/Components/design-elements/StatusBox.tsx b/client/src/Components/design-elements/StatusBox.tsx index 017873eb9..191ed21d6 100644 --- a/client/src/Components/design-elements/StatusBox.tsx +++ b/client/src/Components/design-elements/StatusBox.tsx @@ -5,7 +5,7 @@ import { BaseBox } from "@/Components/design-elements"; import { useTranslation } from "react-i18next"; import type { SxProps } from "@mui/material"; -import { useTheme } from "@mui/material/styles"; +import { alpha, useTheme } from "@mui/material/styles"; type StatusBoxProps = React.PropsWithChildren<{ children: React.ReactNode; @@ -33,9 +33,12 @@ export const BGBox = ({ children, sx }: StatusBoxProps) => { bottom={0} sx={{ pointerEvents: "none", + // Deliberately far fainter than `divider` - this is texture + // behind the number, not a rule, and it must never compete + // with the box's own border. backgroundImage: ` - linear-gradient(${theme.palette.divider} 1px, transparent 1px), - linear-gradient(90deg, ${theme.palette.divider} 1px, transparent 1px) + linear-gradient(${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px), + linear-gradient(90deg, ${alpha(theme.palette.divider, 0.4)} 1px, transparent 1px) `, backgroundSize: "24px 24px", maskImage: diff --git a/client/src/Utils/Theme/Palette.ts b/client/src/Utils/Theme/Palette.ts index bfb9dda5d..a7f4b9a27 100644 --- a/client/src/Utils/Theme/Palette.ts +++ b/client/src/Utils/Theme/Palette.ts @@ -24,14 +24,14 @@ export const colors = { ink600: "#55635C", ink400: "#7C8983", surface50: "#F6F8F7", - rule200: "#D9E1DD", + rule200: "#C7D2CC", // Semantic colours derived from the brand rather than Material's defaults successMain: "#0D6049", errorMain: "#A02B24", warningMain: "#8C5806", inkDark900: "#ECEFED", inkDark600: "#A3ADA8", - ruleDark: "#2C3532", + ruleDark: "#3A4642", successDark: "#4BC49E", errorDark: "#E8837A", warningDark: "#E0A458", From 8582958ad5e2f55cbba06cbc9b845e8b094f3def Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 3 Sep 2026 00:56:29 -0400 Subject: [PATCH 04/15] fix(client): rebuild the stat box gradient from `main` The coloured stat box ran its gradient from `.light` to `.main`. With Material's stock warning that was #ff9800 -> #ed6c02, a bright orange. The new warning.main is a dark amber chosen so the "Paused" chip text clears 4.5:1, and MUI derives `light` from it, so the gradient became brown mud. The two roles were never compatible: `main` has to be dark enough for small text on white, and the same value was being used as a large filled background. The gradient now runs from lighten(main, 0.12) to main, which keeps the box in the status colour, restores the depth that the flat brown lost, and holds white contrastText at 4.64:1 or better at the lightest point for all four palettes. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/design-elements/StatBox.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/Components/design-elements/StatBox.tsx b/client/src/Components/design-elements/StatBox.tsx index addbc7ee9..9cdb9c024 100644 --- a/client/src/Components/design-elements/StatBox.tsx +++ b/client/src/Components/design-elements/StatBox.tsx @@ -23,8 +23,11 @@ export const GradientBox = ({ children, palette, sx, interactive }: GradientBox) // gradient collapses flat. Darken slightly towards the page ground instead. const paperStart = isLight ? darken(paper, 0.02) : lighten(paper, 0.08); const paperEnd = paper; + // The gradient carries white `contrastText`, so it runs between two shades + // dark enough to keep that legible. `light` is a mid-tone used for status + // dots and chart marks - too pale here - so lift from `main` instead. const bg = palette - ? `linear-gradient(135deg, ${theme.palette[palette].light} 0%, ${theme.palette[palette].main} 100%)` + ? `linear-gradient(135deg, ${lighten(theme.palette[palette].main, 0.12)} 0%, ${theme.palette[palette].main} 100%)` : `linear-gradient(135deg, ${paperStart} 0%, ${paperEnd} 100%)`; return ( From 62fb17f64c80d3e923ae5288bc244bed3d01ae0f Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Thu, 3 Sep 2026 01:00:23 -0400 Subject: [PATCH 05/15] fix(client): match header button icons and toggle weight to the rest of the UI Two mismatches in the monitor detail header: - The action button icons rendered at the shared Icon default of 20px, while the sidebar clamps the same lucide icons to 16px in CSS. The buttons were therefore 25% larger than every other icon in the app, and taller than the cap height of the 13px label beside them. Now 16px at all eight call sites. Left at full opacity - the sidebar dims to 0.81 because nav is recessive, but these are active controls. - ToggleButton went to fontWeight 500 when selected while Button is a flat 400, so the time range group sat heavier than the button row above it. Weight now stays at 400 and the tinted fill plus text.primary ink carry the selected state; that is a 2.9x difference in ink against the unselected label, so selection is still obvious. It also stops the label reflowing as you switch range. Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd --- client/src/Components/inputs/ToggleButton.tsx | 5 +- .../monitors/HeaderMonitorControls.tsx | 56 +++++++++++++++++-- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/client/src/Components/inputs/ToggleButton.tsx b/client/src/Components/inputs/ToggleButton.tsx index a2b392357..467c232b6 100644 --- a/client/src/Components/inputs/ToggleButton.tsx +++ b/client/src/Components/inputs/ToggleButton.tsx @@ -33,8 +33,11 @@ export const ToggleButtonInput = ({ sx, ...props }: ToggleButtonProps) => { }, "&.Mui-selected": { backgroundColor: selectedBg, + // Weight stays at 400 so the group sits level with Button, + // which it is routinely placed beside. The fill and the + // darker ink carry the selected state instead - a weight + // change also reflows the label width as you switch. color: theme.palette.text.primary, - fontWeight: 500, "&:hover": { backgroundColor: selectedHoverBg, }, diff --git a/client/src/Components/monitors/HeaderMonitorControls.tsx b/client/src/Components/monitors/HeaderMonitorControls.tsx index 7f6968f0b..a32a33298 100644 --- a/client/src/Components/monitors/HeaderMonitorControls.tsx +++ b/client/src/Components/monitors/HeaderMonitorControls.tsx @@ -64,7 +64,12 @@ export const HeaderMonitorControls = ({ variant="contained" color="secondary" loading={isPosting} - startIcon={} + startIcon={ + + } onClick={async () => { await post(`/notifications/test/all`, { monitorId: monitor.id }); }} @@ -77,7 +82,12 @@ export const HeaderMonitorControls = ({