From 944ee74a1d70fcc108d86c382a2defb8600b16c8 Mon Sep 17 00:00:00 2001
From: gorkem-bwl
Date: Thu, 23 Apr 2026 22:49:50 -0400
Subject: [PATCH 01/19] feat(status-page): four selectable themes for the
public status page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds four fully-designed visual themes for the public status page
(refined | modern | bold | editorial) with a light/dark mode selector,
admin preview in a mock browser frame, and a shared theme provider
that keeps the component tree unified while letting each theme own
its visual identity.
## Selectable themes
- Refined — clean, flat cards, soft shadows, colored status dot.
- Modern — flat surfaces with a pulsing status dot and staggered
fade-in cards.
- Bold — conic-gradient logo, 34px hero title with a check disc,
subtle dual-radial glow on dark mode, accent-green hover border.
- Editorial — serif headings, narrow newspaper column, 'Service
Report' eyebrow + title, flat rectangular badges and dateline.
## Architecture
- Single `StatusPageThemeProvider` hosts CSS custom-property tokens
per theme and resolves light/dark based on admin-forced mode,
falling back to `prefers-color-scheme` with a matchMedia listener.
- Each theme owns only its `*StatusPage.tsx` + scoped CSS file.
The heatmap, histogram, and infrastructure renderers are shared
across all four themes (parameterized by a `classPrefix`) to avoid
the 12-file duplication the initial draft had.
- Renderer lookup table in `Status/index.tsx` replaces a 4-way
ternary chain; unknown themes fall through to the legacy renderer.
- `resolveOverallStatus`, `statusBadgeKey`, `monoFirstChar`, and
`monitorBadgeTone` are extracted to `themes/shared/overallStatus.tsx`.
## Admin UX
- `ThemePicker` uses the project's `RadioWithDescription` and
`Select` components — one radio card per theme plus an Auto/
Light-only/Dark-only mode dropdown.
- `BrowserFrame` wraps the preview in the admin panel with a mock
chrome (traffic lights + URL bar) that adopts the status page's
theme tokens. Edge-to-edge fill with no gray bleed.
- Public route renders the theme directly on the viewport with the
document body painted to the theme background, reverted on unmount.
## Performance & polish
- Histogram's derived stats (padded array, max, avg, peak) are
memoized on `checks`; `max` is reused for `peak` instead of
computing twice.
- Editorial's dateline is wrapped in `useMemo` so the 10s poll
doesn't rerender "April 23, 2026" as a changing string.
- Provider's `setSystemMode` reconciliation uses the updater form
to avoid an extra render when the value is unchanged.
- `monoFirstChar` no longer uses a non-null assertion.
## Form plumbing
- `useStatusPageForm` defaults include `theme` + `themeMode`.
- `Validation/statusPage.ts` accepts both as optional zod enums.
- `Pages/StatusPage/Create/index.tsx` appends both to FormData on
create and update.
## CLAUDE.md
- Dev port note: local client runs on 10001 (5173 is used by a
different local project); server `CLIENT_HOST` is set to match.
---
CLAUDE.md | 4 +-
client/src/Hooks/useStatusPageForm.ts | 6 +
.../Create/Components/ThemePicker.tsx | 66 +++
client/src/Pages/StatusPage/Create/index.tsx | 27 +
.../Components/HeaderStatusPageControls.tsx | 1 +
.../Status/Components/MonitorsList.tsx | 3 +
.../Status/Components/StatusBar.tsx | 197 ++++---
client/src/Pages/StatusPage/Status/index.tsx | 139 +++--
.../StatusPage/Status/themes/BrowserFrame.tsx | 76 +++
.../Status/themes/StatusPageThemeProvider.tsx | 148 +++++
.../Status/themes/bold/BoldStatusPage.tsx | 180 ++++++
.../StatusPage/Status/themes/bold/bold.css | 497 +++++++++++++++++
.../themes/editorial/EditorialStatusPage.tsx | 189 +++++++
.../Status/themes/editorial/editorial.css | 353 ++++++++++++
.../Status/themes/modern/ModernStatusPage.tsx | 179 ++++++
.../Status/themes/modern/modern.css | 519 ++++++++++++++++++
.../themes/refined/RefinedStatusPage.tsx | 179 ++++++
.../Status/themes/refined/refined.css | 420 ++++++++++++++
.../Status/themes/shared/ThemedHeatmap.tsx | 83 +++
.../Status/themes/shared/ThemedHistogram.tsx | 99 ++++
.../themes/shared/ThemedInfrastructure.tsx | 102 ++++
.../Status/themes/shared/overallStatus.tsx | 160 ++++++
.../Status/themes/shared/timeFormat.ts | 12 +
.../Status/themes/theme-overrides.css | 156 ++++++
.../Pages/StatusPage/Status/themes/tokens.ts | 218 ++++++++
client/src/Types/StatusPage.ts | 10 +
client/src/Validation/statusPage.ts | 3 +
client/src/locales/en.json | 55 ++
28 files changed, 3967 insertions(+), 114 deletions(-)
create mode 100644 client/src/Pages/StatusPage/Create/Components/ThemePicker.tsx
create mode 100644 client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx
create mode 100644 client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx
create mode 100644 client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx
create mode 100644 client/src/Pages/StatusPage/Status/themes/bold/bold.css
create mode 100644 client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx
create mode 100644 client/src/Pages/StatusPage/Status/themes/editorial/editorial.css
create mode 100644 client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx
create mode 100644 client/src/Pages/StatusPage/Status/themes/modern/modern.css
create mode 100644 client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx
create mode 100644 client/src/Pages/StatusPage/Status/themes/refined/refined.css
create mode 100644 client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
create mode 100644 client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
create mode 100644 client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx
create mode 100644 client/src/Pages/StatusPage/Status/themes/shared/overallStatus.tsx
create mode 100644 client/src/Pages/StatusPage/Status/themes/shared/timeFormat.ts
create mode 100644 client/src/Pages/StatusPage/Status/themes/theme-overrides.css
create mode 100644 client/src/Pages/StatusPage/Status/themes/tokens.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index 9af1a6742..be7a347c1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,13 +12,15 @@ Checkmate is an open-source uptime and infrastructure monitoring application. It
```bash
cd client
npm install
-npm run dev # Start dev server at http://localhost:5173
+npm run dev -- --port 10001 --strictPort # Local dev port is 10001 (5173 is used by another project on this machine)
npm run build # TypeScript check + production build
npm run lint # ESLint (strict, max-warnings 0)
npm run format # Prettier formatting
npm run format-check # Check formatting
```
+Server `.env` on this machine is configured with `CLIENT_HOST="http://localhost:10001"` to match the client dev port. If you change the client port, keep `.env` in sync.
+
### Server (Node.js/Express)
```bash
cd server
diff --git a/client/src/Hooks/useStatusPageForm.ts b/client/src/Hooks/useStatusPageForm.ts
index 0011ea761..b4ae7fe33 100644
--- a/client/src/Hooks/useStatusPageForm.ts
+++ b/client/src/Hooks/useStatusPageForm.ts
@@ -1,6 +1,10 @@
import { useMemo } from "react";
import { statusPageSchema, type StatusPageFormData } from "@/Validation/statusPage";
import type { StatusPage } from "@/Types/StatusPage";
+import {
+ DEFAULT_STATUS_PAGE_THEME,
+ DEFAULT_STATUS_PAGE_THEME_MODE,
+} from "@/Types/StatusPage";
import type { Monitor } from "@/Types/Monitor";
interface UseStatusPageFormOptions {
@@ -36,6 +40,8 @@ export const useStatusPageForm = ({
showAdminLoginLink: data?.showAdminLoginLink ?? false,
showInfrastructure: data?.showInfrastructure ?? false,
customCSS: data?.customCSS || "",
+ theme: data?.theme ?? DEFAULT_STATUS_PAGE_THEME,
+ themeMode: data?.themeMode ?? DEFAULT_STATUS_PAGE_THEME_MODE,
logo: transformLogo(data?.logo),
};
diff --git a/client/src/Pages/StatusPage/Create/Components/ThemePicker.tsx b/client/src/Pages/StatusPage/Create/Components/ThemePicker.tsx
new file mode 100644
index 000000000..d68e1d0c1
--- /dev/null
+++ b/client/src/Pages/StatusPage/Create/Components/ThemePicker.tsx
@@ -0,0 +1,66 @@
+import { useTranslation } from "react-i18next";
+import Stack from "@mui/material/Stack";
+import RadioGroup from "@mui/material/RadioGroup";
+import MenuItem from "@mui/material/MenuItem";
+import { useTheme } from "@mui/material/styles";
+import { RadioWithDescription, Select } from "@/Components/inputs";
+import {
+ STATUS_PAGE_THEMES,
+ STATUS_PAGE_THEME_MODES,
+ type StatusPageTheme,
+ type StatusPageThemeMode,
+} from "@/Types/StatusPage";
+
+interface Props {
+ theme: StatusPageTheme;
+ themeMode: StatusPageThemeMode;
+ onThemeChange: (value: StatusPageTheme) => void;
+ onThemeModeChange: (value: StatusPageThemeMode) => void;
+}
+
+export const ThemePicker = ({
+ theme,
+ themeMode,
+ onThemeChange,
+ onThemeModeChange,
+}: Props) => {
+ const { t } = useTranslation();
+ const muiTheme = useTheme();
+
+ return (
+
+ onThemeChange(value as StatusPageTheme)}
+ >
+
+ {STATUS_PAGE_THEMES.map((option) => (
+
+ ))}
+
+
+
+ onThemeModeChange(e.target.value as StatusPageThemeMode)}
+ >
+ {STATUS_PAGE_THEME_MODES.map((mode) => (
+
+ {t(`pages.statusPages.form.themeMode.${mode}`)}
+
+ ))}
+
+
+ );
+};
diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx
index f09d9e47d..d972a90c7 100644
--- a/client/src/Pages/StatusPage/Create/index.tsx
+++ b/client/src/Pages/StatusPage/Create/index.tsx
@@ -34,6 +34,7 @@ import timezones from "@/Utils/timezones.json";
import { useNavigate, useParams } from "react-router-dom";
import axios from "axios";
import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusControls";
+import { ThemePicker } from "./Components/ThemePicker";
const monitorsUrl = (() => {
const params = new URLSearchParams();
@@ -139,6 +140,8 @@ const CreateStatusPage = () => {
fd.append("showUptimePercentage", String(data.showUptimePercentage));
fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
fd.append("showInfrastructure", String(data.showInfrastructure));
+ if (data.theme) fd.append("theme", data.theme);
+ if (data.themeMode) fd.append("themeMode", data.themeMode);
data.monitors.forEach((monitorId) => {
fd.append("monitors[]", monitorId);
@@ -438,6 +441,30 @@ const CreateStatusPage = () => {
}
/>
+ (
+ (
+
+ )}
+ />
+ )}
+ />
+ }
+ />
{monitor.name}
{getMonitorTypeLabel(monitor.type, t)}
@@ -153,6 +155,7 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
{monitors.map((monitor) => (
{
- const monitorsStatus: StatusDisplay = {
- icon: ,
- };
-
- // Handle empty monitors array
+const getMonitorStatus = (monitors: Monitor[], t: Function): StatusDisplay => {
if (monitors.length === 0) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.noMonitors");
- monitorsStatus.color = theme.palette.warning.main;
- monitorsStatus.icon = ;
- return monitorsStatus;
+ return {
+ msg: t("pages.statusPages.statusBar.noMonitors"),
+ tone: "warn",
+ icon: ,
+ };
}
const allOf = (...statuses: MonitorStatus[]) =>
@@ -41,75 +38,112 @@ const getMonitorStatus = (monitors: Monitor[], theme: Theme, t: Function) => {
const noneOf = (...statuses: MonitorStatus[]) =>
monitors.every((m) => !statuses.includes(m.status));
- // All monitors in a single state
if (allOf("up")) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.allUp");
- monitorsStatus.color = theme.palette.success.main;
- monitorsStatus.icon = ;
- } else if (allOf("breached")) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.allBreached");
- monitorsStatus.color = theme.palette.error.main;
- monitorsStatus.icon = ;
- } else if (allOf("maintenance")) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.allMaintenance");
- monitorsStatus.color = theme.palette.warning.main;
- monitorsStatus.icon = ;
- } else if (allOf("down")) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.allDown");
- monitorsStatus.color = theme.palette.error.main;
- monitorsStatus.icon = ;
- } else if (allOf("paused")) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.allPaused");
- monitorsStatus.color = theme.palette.warning.main;
- monitorsStatus.icon = ;
- } else if (allOf("initializing")) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.allInitializing");
- monitorsStatus.color = theme.palette.warning.main;
- monitorsStatus.icon = ;
-
- // Breached takes highest priority in mixed states
- } else if (someOf("breached") && someOf("down")) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.breachedAndDown");
- monitorsStatus.color = theme.palette.error.main;
- monitorsStatus.icon = ;
- } else if (someOf("breached")) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.breached");
- monitorsStatus.color = theme.palette.error.main;
- monitorsStatus.icon = ;
-
- // Maintenance combinations
- } else if (someOf("maintenance") && someOf("down")) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.maintenanceAndDown");
- monitorsStatus.color = theme.palette.error.main;
- monitorsStatus.icon = ;
- } else if (someOf("maintenance") && noneOf("down")) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.maintenance");
- monitorsStatus.color = theme.palette.warning.main;
- monitorsStatus.icon = ;
-
- // Degraded (some down, no maintenance/breached)
- } else if (someOf("down")) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.degraded");
- monitorsStatus.color = theme.palette.warning.main;
- monitorsStatus.icon = ;
-
- // Some Paused
- } else if (someOf("paused")) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.partiallyPaused");
- monitorsStatus.color = theme.palette.warning.main;
- monitorsStatus.icon = ;
-
- // Initializing
- } else if (someOf("initializing")) {
- monitorsStatus.msg = t("pages.statusPages.statusBar.initializing");
- monitorsStatus.color = theme.palette.info.main;
- monitorsStatus.icon = ;
- } else {
- monitorsStatus.msg = t("pages.statusPages.statusBar.unknown");
- monitorsStatus.color = theme.palette.warning.main;
+ return {
+ msg: t("pages.statusPages.statusBar.allUp"),
+ tone: "up",
+ icon: ,
+ };
+ }
+ if (allOf("breached")) {
+ return {
+ msg: t("pages.statusPages.statusBar.allBreached"),
+ tone: "down",
+ icon: ,
+ };
+ }
+ if (allOf("maintenance")) {
+ return {
+ msg: t("pages.statusPages.statusBar.allMaintenance"),
+ tone: "warn",
+ icon: ,
+ };
+ }
+ if (allOf("down")) {
+ return {
+ msg: t("pages.statusPages.statusBar.allDown"),
+ tone: "down",
+ icon: ,
+ };
+ }
+ if (allOf("paused")) {
+ return {
+ msg: t("pages.statusPages.statusBar.allPaused"),
+ tone: "warn",
+ icon: ,
+ };
+ }
+ if (allOf("initializing")) {
+ return {
+ msg: t("pages.statusPages.statusBar.allInitializing"),
+ tone: "warn",
+ icon: ,
+ };
}
- return monitorsStatus;
+ // Breached takes highest priority in mixed states
+ if (someOf("breached") && someOf("down")) {
+ return {
+ msg: t("pages.statusPages.statusBar.breachedAndDown"),
+ tone: "down",
+ icon: ,
+ };
+ }
+ if (someOf("breached")) {
+ return {
+ msg: t("pages.statusPages.statusBar.breached"),
+ tone: "down",
+ icon: ,
+ };
+ }
+ if (someOf("maintenance") && someOf("down")) {
+ return {
+ msg: t("pages.statusPages.statusBar.maintenanceAndDown"),
+ tone: "down",
+ icon: ,
+ };
+ }
+ if (someOf("maintenance") && noneOf("down")) {
+ return {
+ msg: t("pages.statusPages.statusBar.maintenance"),
+ tone: "warn",
+ icon: ,
+ };
+ }
+ if (someOf("down")) {
+ return {
+ msg: t("pages.statusPages.statusBar.degraded"),
+ tone: "warn",
+ icon: ,
+ };
+ }
+ if (someOf("paused")) {
+ return {
+ msg: t("pages.statusPages.statusBar.partiallyPaused"),
+ tone: "warn",
+ icon: ,
+ };
+ }
+ if (someOf("initializing")) {
+ return {
+ msg: t("pages.statusPages.statusBar.initializing"),
+ tone: "info",
+ icon: ,
+ };
+ }
+
+ return {
+ msg: t("pages.statusPages.statusBar.unknown"),
+ tone: "warn",
+ icon: ,
+ };
+};
+
+const toneToCssVar: Record = {
+ up: "var(--sp-up)",
+ down: "var(--sp-down)",
+ warn: "var(--sp-warn)",
+ info: "var(--sp-up)",
};
interface StatusBarProps {
@@ -119,7 +153,7 @@ interface StatusBarProps {
export const StatusBar = ({ monitors }: StatusBarProps) => {
const theme = useTheme();
const { t } = useTranslation();
- const monitorsStatus = getMonitorStatus(monitors, theme, t);
+ const monitorsStatus = getMonitorStatus(monitors, t);
return (
{
justifyContent="center"
gap={theme.spacing(2)}
height={theme.spacing(30)}
- bgcolor={monitorsStatus.color}
+ bgcolor={toneToCssVar[monitorsStatus.tone]}
borderRadius={theme.shape.borderRadius}
+ className="sp-hero"
>
{monitorsStatus.icon}
- {monitorsStatus.msg}
+ {monitorsStatus.msg}
);
};
diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx
index 78b7cba41..a4864d748 100644
--- a/client/src/Pages/StatusPage/Status/index.tsx
+++ b/client/src/Pages/StatusPage/Status/index.tsx
@@ -11,8 +11,29 @@ import { useTranslation } from "react-i18next";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useLocation, useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
-import type { StatusPageResponse } from "@/Types/StatusPage";
+import type { Monitor } from "@/Types/Monitor";
+import type { StatusPage, StatusPageResponse, StatusPageTheme } from "@/Types/StatusPage";
import { HeaderStatusPageControls } from "./Components/HeaderStatusPageControls";
+import { StatusPageThemeProvider } from "./themes/StatusPageThemeProvider";
+import { RefinedStatusPage } from "./themes/refined/RefinedStatusPage";
+import { ModernStatusPage } from "./themes/modern/ModernStatusPage";
+import { BoldStatusPage } from "./themes/bold/BoldStatusPage";
+import { EditorialStatusPage } from "./themes/editorial/EditorialStatusPage";
+import { BrowserFrame } from "./themes/BrowserFrame";
+
+type ThemedRendererProps = {
+ statusPage: StatusPage;
+ monitors: (Monitor & { checks?: Monitor["recentChecks"] })[];
+};
+
+const THEMED_RENDERERS: Partial<
+ Record>
+> = {
+ refined: RefinedStatusPage,
+ modern: ModernStatusPage,
+ bold: BoldStatusPage,
+ editorial: EditorialStatusPage,
+};
const StatusPageView = () => {
const theme = useTheme();
@@ -76,38 +97,92 @@ const StatusPageView = () => {
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
- return (
-
-
- {logoSrc && (
-
+ ) : null;
+
+ if (themedRenderer) {
+ // Public route: render directly on the viewport, themed background covers everything.
+ if (isPublic) {
+ return (
+
+ {themedRenderer}
+
+ );
+ }
+
+ // Admin preview: wrap in a mock browser frame inside the admin shell.
+ const publicUrl = `${window.location.origin}/status/public/${statusPage.url}`;
+ return (
+
+
- )}
-
-
-
+
+ {themedRenderer}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {logoSrc && (
+
+ )}
+
+
+
+
);
};
diff --git a/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx
new file mode 100644
index 000000000..55e62c440
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/BrowserFrame.tsx
@@ -0,0 +1,76 @@
+import type { ReactNode } from "react";
+import Box from "@mui/material/Box";
+import { useTheme } from "@mui/material/styles";
+
+interface Props {
+ url: string;
+ children: ReactNode;
+}
+
+export const BrowserFrame = ({ url, children }: Props) => {
+ const theme = useTheme();
+
+ return (
+
+
+
+
+
+
+
+
+ {url}
+
+
+ {children}
+
+ );
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx
new file mode 100644
index 000000000..294198330
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx
@@ -0,0 +1,148 @@
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+import {
+ DEFAULT_STATUS_PAGE_THEME,
+ DEFAULT_STATUS_PAGE_THEME_MODE,
+ type StatusPageTheme,
+ type StatusPageThemeMode,
+} from "@/Types/StatusPage";
+import { themeTokens, toCssVars, type StatusPageThemeTokens } from "./tokens";
+import "./theme-overrides.css";
+
+type ResolvedMode = "light" | "dark";
+
+interface StatusPageThemeContextValue {
+ theme: StatusPageTheme;
+ mode: ResolvedMode;
+ tokens: StatusPageThemeTokens;
+}
+
+const StatusPageThemeContext = createContext(null);
+
+const resolveSystemMode = (): ResolvedMode => {
+ if (typeof window === "undefined" || !window.matchMedia) return "light";
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
+};
+
+interface Props {
+ theme?: StatusPageTheme;
+ themeMode?: StatusPageThemeMode;
+ /**
+ * When true, paint the document body with the theme background so it
+ * covers beyond the provider's wrapper (useful on the public route
+ * where no admin shell sits behind). Defaults to false for admin
+ * previews that already have an app background.
+ */
+ paintBody?: boolean;
+ /**
+ * When true, the provider's wrapper div does NOT paint its own
+ * background or set a min-height. Use this when the wrapper is
+ * inside a clipped container (e.g. BrowserFrame) that already sets
+ * the visible background, so the wrapper's bg doesn't bleed past the
+ * container's rounded corners.
+ */
+ transparent?: boolean;
+ children: ReactNode;
+}
+
+export const StatusPageThemeProvider = ({
+ theme,
+ themeMode,
+ paintBody = false,
+ transparent = false,
+ children,
+}: Props) => {
+ const resolvedTheme = theme ?? DEFAULT_STATUS_PAGE_THEME;
+ const resolvedThemeMode = themeMode ?? DEFAULT_STATUS_PAGE_THEME_MODE;
+
+ const [systemMode, setSystemMode] = useState(resolveSystemMode);
+
+ useEffect(() => {
+ if (resolvedThemeMode !== "auto") return;
+ if (typeof window === "undefined" || !window.matchMedia) return;
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
+ // Reconcile on first paint whenever we (re-)enter auto mode — only
+ // when it disagrees with the stored value, so we don't trigger an
+ // extra render on the common hot path.
+ const next: ResolvedMode = mq.matches ? "dark" : "light";
+ setSystemMode((prev) => (prev === next ? prev : next));
+ const handler = (e: MediaQueryListEvent) =>
+ setSystemMode(e.matches ? "dark" : "light");
+ mq.addEventListener("change", handler);
+ return () => mq.removeEventListener("change", handler);
+ }, [resolvedThemeMode]);
+
+ useEffect(() => {
+ if (!paintBody || typeof document === "undefined") return;
+ const html = document.documentElement;
+ const body = document.body;
+ const prev = {
+ htmlBg: html.style.background,
+ bodyBg: body.style.background,
+ bodyMargin: body.style.margin,
+ htmlColorScheme: html.style.colorScheme,
+ };
+ const resolvedMode = resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode;
+ const bg = themeTokens[theme ?? DEFAULT_STATUS_PAGE_THEME][resolvedMode].bg;
+ html.style.background = bg;
+ body.style.background = bg;
+ body.style.margin = "0";
+ html.style.colorScheme = resolvedMode;
+ return () => {
+ html.style.background = prev.htmlBg;
+ body.style.background = prev.bodyBg;
+ body.style.margin = prev.bodyMargin;
+ html.style.colorScheme = prev.htmlColorScheme;
+ };
+ }, [paintBody, theme, resolvedThemeMode, systemMode]);
+
+ const resolvedMode: ResolvedMode =
+ resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode;
+ const tokens = themeTokens[resolvedTheme][resolvedMode];
+
+ const style = useMemo(() => toCssVars(tokens) as React.CSSProperties, [tokens]);
+
+ const value = useMemo(
+ () => ({ theme: resolvedTheme, mode: resolvedMode, tokens }),
+ [resolvedTheme, resolvedMode, tokens]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export const useStatusPageTheme = (): StatusPageThemeContextValue => {
+ const ctx = useContext(StatusPageThemeContext);
+ if (!ctx) {
+ return {
+ theme: DEFAULT_STATUS_PAGE_THEME,
+ mode: resolveSystemMode(),
+ tokens: themeTokens[DEFAULT_STATUS_PAGE_THEME][resolveSystemMode()],
+ };
+ }
+ return ctx;
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx
new file mode 100644
index 000000000..ad777dbff
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx
@@ -0,0 +1,180 @@
+import { useState } from "react";
+import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import { useTranslation } from "react-i18next";
+import type { Monitor } from "@/Types/Monitor";
+import type { StatusPage } from "@/Types/StatusPage";
+import { getMonitorTypeLabel } from "@/Types/StatusPage";
+import { ThemedHeatmap } from "../shared/ThemedHeatmap";
+import { ThemedHistogram } from "../shared/ThemedHistogram";
+import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
+import {
+ monitorBadgeTone,
+ monoFirstChar,
+ resolveOverallStatus,
+ statusBadgeKey,
+} from "../shared/overallStatus";
+import "./bold.css";
+
+type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
+
+interface Props {
+ statusPage: StatusPage;
+ monitors: StatusPageMonitor[];
+}
+
+const PREFIX = "bo";
+
+export const BoldStatusPage = ({ statusPage, monitors }: Props) => {
+ const { t } = useTranslation();
+ const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
+
+ // Bold uses a dedicated check-disc treatment next to the title; its
+ // icon is scaled differently but the semantic resolution is shared.
+ const overall = resolveOverallStatus(monitors, t, { iconSize: 18 });
+ const logoSrc = statusPage.logo?.data
+ ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
+ : null;
+
+ return (
+
+
+
+ {logoSrc ? (
+
+ ) : (
+
+ {monoFirstChar(statusPage.companyName)}
+
+ )}
+ {statusPage.companyName}
+
+
+
+
+
+ {overall.icon}
+ {overall.message}
+
+
+ {t("pages.statusPages.statusBar.monitoringSummary", {
+ count: monitors.length,
+ })}
+
+
+
+ {statusPage.showCharts && (
+
+
+ setChartMode("heatmap")}
+ >
+ {t("pages.statusPages.monitorsList.chartTypeHeatmap")}
+
+ setChartMode("histogram")}
+ >
+ {t("pages.statusPages.monitorsList.chartTypeHistogram")}
+
+
+
+ )}
+
+
+ {monitors.map((monitor) => {
+ const isHardware = monitor.type === "hardware";
+ const showInfra = isHardware && statusPage.showInfrastructure !== false;
+ const showChart = !isHardware && statusPage.showCharts !== false;
+ const badgeTone = monitorBadgeTone(monitor.status);
+
+ return (
+
+
+
+ {monitor.name}
+
+
+ {getMonitorTypeLabel(monitor.type, t)}
+
+ {monitor.url && (
+
+ {monitor.url}
+
+ )}
+
+
+
+ {t(statusBadgeKey[monitor.status])}
+
+
+
+ {showInfra && (
+
+ )}
+ {showChart &&
+ (chartMode === "heatmap" ? (
+
+ ) : (
+
+ ))}
+
+ );
+ })}
+
+
+
+ {t("pages.statusPages.footer.poweredBy")}{" "}
+
+ Checkmate
+
+
+
+ );
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/bold/bold.css b/client/src/Pages/StatusPage/Status/themes/bold/bold.css
new file mode 100644
index 000000000..a6e44db47
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/bold/bold.css
@@ -0,0 +1,497 @@
+/*
+ * Bold theme — scoped to [data-status-theme="bold"].
+ * Port of /Users/gorkemcetin/Desktop/checkmate-status-mockups/03-bold-dark.html.
+ * Signature traits: conic-gradient logo, dual-radial-glow body, large
+ * 34px hero title with a check-disc inline, glowing chart cells/bars,
+ * 18px-radius cards with staggered fade-in and accent-green hover.
+ */
+
+[data-status-theme="bold"]:not([data-status-transparent]) {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ background:
+ radial-gradient(
+ 900px 500px at 15% -10%,
+ color-mix(in srgb, var(--sp-up) 4%, transparent),
+ transparent 60%
+ ),
+ radial-gradient(
+ 900px 500px at 95% 10%,
+ color-mix(in srgb, var(--sp-up-strong) 3%, transparent),
+ transparent 60%
+ ),
+ var(--sp-bg);
+}
+[data-status-theme="bold"][data-status-mode="dark"]:not([data-status-transparent]) {
+ background:
+ radial-gradient(
+ 900px 500px at 15% -10%,
+ color-mix(in srgb, var(--sp-up) 8%, transparent),
+ transparent 60%
+ ),
+ radial-gradient(
+ 900px 500px at 95% 10%,
+ color-mix(in srgb, var(--sp-up-strong) 9%, transparent),
+ transparent 60%
+ ),
+ var(--sp-bg);
+}
+
+[data-status-theme="bold"] .bo-page {
+ flex: 1 0 auto;
+ max-width: 1040px;
+ width: 100%;
+ margin: 0 auto;
+ padding: 56px 24px 96px;
+ font:
+ 14px/1.55 ui-sans-serif,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Inter",
+ "Segoe UI",
+ Roboto,
+ sans-serif;
+ color: var(--sp-text);
+ -webkit-font-smoothing: antialiased;
+}
+
+/* --- header / conic logo --- */
+[data-status-theme="bold"] .bo-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 36px;
+}
+[data-status-theme="bold"] .bo-brand {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ font-weight: 700;
+ font-size: 16px;
+ letter-spacing: -0.01em;
+ color: var(--sp-text);
+}
+[data-status-theme="bold"] .bo-logo-conic {
+ width: 36px;
+ height: 36px;
+ border-radius: 12px;
+ background: conic-gradient(
+ from 140deg,
+ var(--sp-up),
+ var(--sp-up-strong),
+ #0f766e,
+ var(--sp-up)
+ );
+ display: grid;
+ place-items: center;
+ color: #05070a;
+ font-weight: 900;
+ font-size: 14px;
+ box-shadow:
+ 0 8px 24px color-mix(in srgb, var(--sp-up) 30%, transparent),
+ inset 0 1px 0 rgba(255, 255, 255, 0.2);
+}
+[data-status-theme="bold"] .bo-logo-img {
+ max-height: 36px;
+ max-width: 140px;
+ object-fit: contain;
+}
+
+/* --- hero: gradient overlay + dual radial glow + big title with check disc --- */
+[data-status-theme="bold"] .bo-hero {
+ position: relative;
+ border-radius: 22px;
+ padding: 36px 36px 32px;
+ margin-bottom: 28px;
+ background:
+ linear-gradient(
+ 135deg,
+ color-mix(in srgb, var(--sp-up) 8%, transparent),
+ transparent 60%
+ ),
+ var(--sp-surface);
+ border: 1px solid var(--sp-border);
+ overflow: hidden;
+}
+[data-status-theme="bold"][data-status-mode="dark"] .bo-hero {
+ background:
+ linear-gradient(
+ 135deg,
+ color-mix(in srgb, var(--sp-up) 16%, transparent),
+ transparent 60%
+ ),
+ var(--sp-surface);
+}
+[data-status-theme="bold"] .bo-hero::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(
+ 400px 200px at 20% 30%,
+ color-mix(in srgb, var(--sp-up) 14%, transparent),
+ transparent 60%
+ ),
+ radial-gradient(
+ 300px 200px at 90% 80%,
+ color-mix(in srgb, var(--sp-up-strong) 10%, transparent),
+ transparent 60%
+ );
+ pointer-events: none;
+ opacity: 0.8;
+ z-index: 0;
+}
+[data-status-theme="bold"][data-status-mode="dark"] .bo-hero::before {
+ background:
+ radial-gradient(
+ 400px 200px at 20% 30%,
+ color-mix(in srgb, var(--sp-up) 32%, transparent),
+ transparent 60%
+ ),
+ radial-gradient(
+ 300px 200px at 90% 80%,
+ color-mix(in srgb, var(--sp-up-strong) 22%, transparent),
+ transparent 60%
+ );
+ mix-blend-mode: screen;
+ opacity: 0.8;
+}
+[data-status-theme="bold"] .bo-hero > * {
+ position: relative;
+ z-index: 1;
+}
+[data-status-theme="bold"] .bo-hero-title {
+ margin: 0;
+ font-size: 34px;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ line-height: 1.1;
+ color: var(--sp-text);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+[data-status-theme="bold"] .bo-hero-check {
+ display: inline-grid;
+ place-items: center;
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: var(--sp-up);
+ color: #05070a;
+ flex-shrink: 0;
+ box-shadow: 0 0 0 6px var(--sp-up-soft);
+}
+[data-status-theme="bold"] .bo-hero.bo-tone-warn .bo-hero-check {
+ background: var(--sp-warn);
+ box-shadow: 0 0 0 6px var(--sp-warn-soft);
+}
+[data-status-theme="bold"] .bo-hero.bo-tone-down .bo-hero-check {
+ background: var(--sp-down);
+ color: #fff;
+ box-shadow: 0 0 0 6px var(--sp-down-soft);
+}
+[data-status-theme="bold"] .bo-hero-sub {
+ margin: 10px 0 0;
+ color: var(--sp-text-muted);
+ font-size: 14px;
+}
+
+/* --- chart toggle --- */
+[data-status-theme="bold"] .bo-chart-switch-wrap {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 16px;
+}
+[data-status-theme="bold"] .bo-chart-switch {
+ display: inline-flex;
+ border: 1px solid var(--sp-border);
+ border-radius: 999px;
+ background: var(--sp-surface);
+ padding: 3px;
+ gap: 2px;
+}
+[data-status-theme="bold"] .bo-chart-switch > button {
+ border: 0;
+ background: transparent;
+ font: inherit;
+ font-size: 11px;
+ padding: 8px 18px;
+ cursor: pointer;
+ color: var(--sp-text-muted);
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ transition:
+ background 0.2s,
+ color 0.2s;
+ border-radius: 999px;
+}
+[data-status-theme="bold"] .bo-chart-switch > button:hover {
+ color: var(--sp-text);
+}
+[data-status-theme="bold"] .bo-chart-switch > button.bo-active {
+ color: var(--sp-up);
+ background: var(--sp-up-soft);
+}
+
+/* --- cards: 18px radius, staggered fade-in, accent-green border on hover --- */
+[data-status-theme="bold"] .bo-monitor-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+[data-status-theme="bold"] .bo-card {
+ background: var(--sp-surface);
+ border: 1px solid var(--sp-border);
+ border-radius: 18px;
+ overflow: hidden;
+ transition:
+ transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1),
+ border-color 0.2s;
+}
+[data-status-theme="bold"] .bo-card:hover {
+ transform: translateY(-3px);
+ border-color: color-mix(in srgb, var(--sp-up) 40%, var(--sp-border));
+}
+
+[data-status-theme="bold"] .bo-card-row {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ gap: 24px;
+ padding: 22px 28px;
+}
+[data-status-theme="bold"] .bo-card-left {
+ min-width: 0;
+}
+[data-status-theme="bold"] .bo-monitor-name {
+ font-weight: 700;
+ font-size: 17px;
+ letter-spacing: -0.01em;
+ color: var(--sp-text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+[data-status-theme="bold"] .bo-monitor-meta {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ margin-top: 6px;
+ flex-wrap: wrap;
+}
+[data-status-theme="bold"] .bo-pill {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--sp-text-muted);
+ background: color-mix(in srgb, var(--sp-surface) 80%, var(--sp-bg));
+ border: 1px solid var(--sp-border);
+ padding: 3px 10px;
+ border-radius: 999px;
+ font-weight: 700;
+}
+[data-status-theme="bold"] .bo-pill-hardware {
+ color: var(--sp-up);
+ border-color: color-mix(in srgb, var(--sp-up) 40%, transparent);
+ background: var(--sp-up-soft);
+}
+[data-status-theme="bold"] .bo-monitor-url {
+ font-size: 12px;
+ color: var(--sp-text-muted);
+ font-family: ui-monospace, Menlo, monospace;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 300px;
+}
+
+/* --- badge: glowing dot --- */
+[data-status-theme="bold"] .bo-badge {
+ font-size: 11px;
+ font-weight: 800;
+ padding: 6px 14px;
+ border-radius: 999px;
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ white-space: nowrap;
+}
+[data-status-theme="bold"] .bo-badge::before {
+ content: "";
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+ box-shadow: 0 0 8px currentColor;
+}
+[data-status-theme="bold"] .bo-badge-up {
+ background: var(--sp-up-soft);
+ color: var(--sp-up);
+}
+[data-status-theme="bold"] .bo-badge-warn {
+ background: var(--sp-warn-soft);
+ color: var(--sp-warn);
+}
+[data-status-theme="bold"] .bo-badge-down {
+ background: var(--sp-down-soft);
+ color: var(--sp-down);
+}
+
+/* --- heatmap: glowing cells --- */
+[data-status-theme="bold"] .bo-heatmap {
+ padding: 0 28px 22px;
+ display: grid;
+ grid-template-columns: repeat(25, 1fr);
+ gap: 3px;
+ height: 48px;
+}
+[data-status-theme="bold"] .bo-heatmap-cell {
+ border-radius: 3px;
+ background: var(--sp-up);
+ transition: transform 0.15s;
+ box-shadow: 0 0 6px color-mix(in srgb, var(--sp-up) 18%, transparent);
+}
+[data-status-theme="bold"] .bo-heatmap-cell:hover {
+ transform: scaleY(1.2);
+}
+[data-status-theme="bold"] .bo-heatmap-cell.bo-fast {
+ background: var(--sp-up);
+}
+[data-status-theme="bold"] .bo-heatmap-cell.bo-med {
+ background: color-mix(in srgb, var(--sp-up) 70%, #ffffff 30%);
+}
+[data-status-theme="bold"] .bo-heatmap-cell.bo-slow {
+ background: var(--sp-warn);
+ box-shadow: 0 0 6px color-mix(in srgb, var(--sp-warn) 20%, transparent);
+}
+[data-status-theme="bold"] .bo-heatmap-cell.bo-down {
+ background: var(--sp-down);
+ box-shadow: 0 0 6px color-mix(in srgb, var(--sp-down) 20%, transparent);
+}
+[data-status-theme="bold"] .bo-heatmap-cell.bo-empty {
+ background: var(--sp-border);
+ box-shadow: none;
+ opacity: 0.4;
+}
+
+/* --- histogram: glowing gradient bars --- */
+[data-status-theme="bold"] .bo-histogram {
+ padding: 0 28px;
+ display: grid;
+ grid-template-columns: repeat(25, 1fr);
+ gap: 3px;
+ align-items: flex-end;
+ height: 48px;
+}
+[data-status-theme="bold"] .bo-bar {
+ background: var(--sp-up);
+ border-radius: 3px;
+ min-height: 4px;
+}
+[data-status-theme="bold"] .bo-bar.bo-down {
+ background: var(--sp-down);
+}
+[data-status-theme="bold"] .bo-bar.bo-empty {
+ background: var(--sp-border);
+ box-shadow: none;
+ opacity: 0.4;
+}
+[data-status-theme="bold"] .bo-chart-stats {
+ padding: 0 28px 22px;
+ font-size: 11px;
+ color: var(--sp-text-muted);
+ font-variant-numeric: tabular-nums;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+}
+
+/* --- gauges: larger, bolder, 16px radius --- */
+[data-status-theme="bold"] .bo-infra {
+ padding: 18px 28px 26px;
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 16px;
+}
+@media (max-width: 720px) {
+ [data-status-theme="bold"] .bo-infra {
+ grid-template-columns: 1fr;
+ }
+}
+[data-status-theme="bold"] .bo-infra-empty {
+ padding: 18px 28px 26px;
+ color: var(--sp-text-muted);
+ font-size: 13px;
+}
+[data-status-theme="bold"] .bo-gauge {
+ border: 1px solid var(--sp-border);
+ border-radius: 16px;
+ padding: 16px 18px;
+ background: color-mix(in srgb, var(--sp-surface) 80%, var(--sp-bg));
+}
+[data-status-theme="bold"] .bo-gauge-label {
+ font-size: 10px;
+ color: var(--sp-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-weight: 700;
+}
+[data-status-theme="bold"] .bo-gauge-value {
+ font-size: 30px;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ margin-top: 6px;
+ font-variant-numeric: tabular-nums;
+ color: var(--sp-text);
+}
+[data-status-theme="bold"] .bo-gauge-bar {
+ height: 6px;
+ background: var(--sp-border);
+ border-radius: 3px;
+ overflow: hidden;
+ margin-top: 12px;
+}
+[data-status-theme="bold"] .bo-gauge-fill {
+ display: block;
+ height: 100%;
+ background: linear-gradient(
+ 90deg,
+ var(--sp-up),
+ color-mix(in srgb, var(--sp-up) 70%, #ffffff 30%)
+ );
+ border-radius: 3px;
+ transition: width 0.9s cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+[data-status-theme="bold"] .bo-gauge-fill.bo-warm {
+ background: linear-gradient(90deg, var(--sp-warn), #fbbf24);
+}
+[data-status-theme="bold"] .bo-gauge-fill.bo-hot {
+ background: linear-gradient(90deg, var(--sp-down), #fb7185);
+}
+[data-status-theme="bold"] .bo-gauge-sub {
+ font-size: 11px;
+ color: var(--sp-text-muted);
+ margin-top: 10px;
+ font-variant-numeric: tabular-nums;
+}
+
+/* --- footer --- */
+[data-status-theme="bold"] .bo-footer {
+ text-align: center;
+ color: var(--sp-text-muted);
+ font-size: 12px;
+ margin-top: 52px;
+}
+[data-status-theme="bold"] .bo-footer a {
+ color: var(--sp-up);
+ text-decoration: underline;
+ text-underline-offset: 3px;
+ font-weight: 800;
+}
diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx
new file mode 100644
index 000000000..306ae2bc0
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx
@@ -0,0 +1,189 @@
+import { useMemo, useState } from "react";
+import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import { useTranslation } from "react-i18next";
+import type { Monitor } from "@/Types/Monitor";
+import type { StatusPage } from "@/Types/StatusPage";
+import { getMonitorTypeLabel } from "@/Types/StatusPage";
+import { ThemedHeatmap } from "../shared/ThemedHeatmap";
+import { ThemedHistogram } from "../shared/ThemedHistogram";
+import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
+import {
+ monitorBadgeTone,
+ resolveOverallStatus,
+ statusBadgeKey,
+} from "../shared/overallStatus";
+import "./editorial.css";
+
+type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
+
+interface Props {
+ statusPage: StatusPage;
+ monitors: StatusPageMonitor[];
+}
+
+const PREFIX = "ed";
+
+export const EditorialStatusPage = ({ statusPage, monitors }: Props) => {
+ const { t } = useTranslation();
+ const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
+
+ // Stable date across re-renders; the page polls every 10s and we don't
+ // want the dateline to jitter.
+ const todayLabel = useMemo(
+ () =>
+ new Date().toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ }),
+ []
+ );
+
+ const overall = resolveOverallStatus(monitors, t, {
+ allUpKey: "pages.statusPages.editorial.allUp",
+ });
+ const logoSrc = statusPage.logo?.data
+ ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
+ : null;
+
+ return (
+
+
+
+ {logoSrc && (
+
+ )}
+ {statusPage.companyName}
+
+ {t("pages.statusPages.editorial.reportTitle")}
+
+
+
+
+
+
+ {overall.message}
+
+
+ {todayLabel} ·{" "}
+ {t("pages.statusPages.statusBar.monitoringSummary", {
+ count: monitors.length,
+ })}
+
+
+ {statusPage.showCharts && (
+
+
+ setChartMode("heatmap")}
+ >
+ {t("pages.statusPages.monitorsList.chartTypeHeatmap")}
+
+ setChartMode("histogram")}
+ >
+ {t("pages.statusPages.monitorsList.chartTypeHistogram")}
+
+
+
+ )}
+
+
+ {monitors.map((monitor) => {
+ const isHardware = monitor.type === "hardware";
+ const showInfra = isHardware && statusPage.showInfrastructure !== false;
+ const showChart = !isHardware && statusPage.showCharts !== false;
+ const badgeTone = monitorBadgeTone(monitor.status);
+
+ return (
+
+
+
+ {monitor.name}
+
+
+ {getMonitorTypeLabel(monitor.type, t)}
+
+ {monitor.url && (
+
+ {monitor.url}
+
+ )}
+
+
+
+ {t(statusBadgeKey[monitor.status])}
+
+
+
+ {showInfra && (
+
+ )}
+ {showChart &&
+ (chartMode === "heatmap" ? (
+
+ ) : (
+
+ ))}
+
+ );
+ })}
+
+
+
+ {t("pages.statusPages.footer.poweredBy")}{" "}
+
+ Checkmate
+
+
+
+ );
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/editorial.css b/client/src/Pages/StatusPage/Status/themes/editorial/editorial.css
new file mode 100644
index 000000000..3cab68b6f
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/editorial/editorial.css
@@ -0,0 +1,353 @@
+/*
+ * Editorial theme — scoped to [data-status-theme="editorial"].
+ * Port of /Users/gorkemcetin/Desktop/checkmate-status-mockups/04-editorial.html.
+ * Signature traits: serif body, narrow 760px column, newspaper-style
+ * header with eyebrow + big "Service Report" title, flat rectangular
+ * badges (no border-radius), hairline dividers instead of cards,
+ * dateline below status, serif gauge values.
+ */
+
+[data-status-theme="editorial"]:not([data-status-transparent]) {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ background: var(--sp-bg);
+}
+
+[data-status-theme="editorial"] .ed-page {
+ flex: 1 0 auto;
+ max-width: 760px;
+ width: 100%;
+ margin: 0 auto;
+ padding: 64px 24px 96px;
+ font:
+ 15px/1.6 Georgia,
+ "Iowan Old Style",
+ "Palatino Linotype",
+ serif;
+ color: var(--sp-text);
+ -webkit-font-smoothing: antialiased;
+}
+
+/* --- header: eyebrow caps + serif h1, 2px underline under whole block --- */
+[data-status-theme="editorial"] .ed-top {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ margin-bottom: 48px;
+ border-bottom: 2px solid var(--sp-text);
+ padding-bottom: 18px;
+}
+[data-status-theme="editorial"] .ed-brand-wrap {
+ display: flex;
+ flex-direction: column;
+}
+[data-status-theme="editorial"] .ed-logo-img {
+ max-height: 48px;
+ max-width: 160px;
+ object-fit: contain;
+ margin-bottom: 8px;
+}
+[data-status-theme="editorial"] .ed-brand-eyebrow {
+ margin: 0;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.3em;
+ font-weight: 700;
+ color: var(--sp-text-muted);
+ font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
+}
+[data-status-theme="editorial"] .ed-brand-title {
+ margin: 6px 0 0;
+ font-size: 40px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ line-height: 1;
+ color: var(--sp-text);
+}
+
+/* --- status line: single sentence, not a card --- */
+[data-status-theme="editorial"] .ed-status-line {
+ font-size: 22px;
+ font-weight: 400;
+ margin: 0 0 8px;
+ letter-spacing: -0.01em;
+ line-height: 1.35;
+ color: var(--sp-text);
+ font-family: Georgia, "Iowan Old Style", "Palatino Linotype", serif;
+}
+[data-status-theme="editorial"] .ed-status-dot {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--sp-up);
+ margin-right: 10px;
+ vertical-align: middle;
+}
+[data-status-theme="editorial"] .ed-status-line.ed-tone-warn .ed-status-dot {
+ background: var(--sp-warn);
+}
+[data-status-theme="editorial"] .ed-status-line.ed-tone-down .ed-status-dot {
+ background: var(--sp-down);
+}
+[data-status-theme="editorial"] .ed-dateline {
+ font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: var(--sp-text-muted);
+ margin: 0 0 56px;
+}
+
+/* --- chart switch: rectangular, dark fill on active (no radius) --- */
+[data-status-theme="editorial"] .ed-chart-switch-wrap {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 20px;
+ font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
+}
+[data-status-theme="editorial"] .ed-chart-switch {
+ display: inline-flex;
+ border: 1px solid var(--sp-text);
+}
+[data-status-theme="editorial"] .ed-chart-switch > button {
+ font: inherit;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.2em;
+ padding: 6px 14px;
+ border: 0;
+ background: transparent;
+ color: var(--sp-text);
+ cursor: pointer;
+ font-weight: 700;
+}
+[data-status-theme="editorial"] .ed-chart-switch > button + button {
+ border-left: 1px solid var(--sp-text);
+}
+[data-status-theme="editorial"] .ed-chart-switch > button.ed-active {
+ background: var(--sp-text);
+ color: var(--sp-bg);
+}
+
+/* --- monitor cards: transparent rows with bottom hairline --- */
+[data-status-theme="editorial"] .ed-monitor-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+}
+[data-status-theme="editorial"] .ed-card {
+ background: transparent;
+ border: 0;
+ border-bottom: 1px solid var(--sp-border);
+ margin-bottom: 0;
+ padding: 20px 0;
+}
+[data-status-theme="editorial"] .ed-card:last-of-type {
+ border-bottom: 0;
+}
+
+[data-status-theme="editorial"] .ed-card-row {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: baseline;
+ gap: 20px;
+}
+[data-status-theme="editorial"] .ed-card-left {
+ min-width: 0;
+}
+[data-status-theme="editorial"] .ed-monitor-name {
+ font-size: 20px;
+ font-weight: 700;
+ letter-spacing: -0.015em;
+ color: var(--sp-text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+[data-status-theme="editorial"] .ed-monitor-meta {
+ font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
+ font-size: 12px;
+ color: var(--sp-text-muted);
+ margin-top: 6px;
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+[data-status-theme="editorial"] .ed-pill {
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-weight: 700;
+ color: var(--sp-text-muted);
+}
+[data-status-theme="editorial"] .ed-pill-hardware {
+ color: var(--sp-up);
+}
+[data-status-theme="editorial"] .ed-monitor-url {
+ font-family: ui-monospace, Menlo, monospace;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 280px;
+ font-size: 12px;
+}
+
+/* --- badge: rectangular flat label --- */
+[data-status-theme="editorial"] .ed-badge {
+ font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
+ font-size: 10px;
+ font-weight: 700;
+ padding: 4px 10px;
+ border-radius: 0;
+ text-transform: uppercase;
+ letter-spacing: 0.15em;
+ white-space: nowrap;
+}
+[data-status-theme="editorial"] .ed-badge-up {
+ background: var(--sp-up-soft);
+ color: var(--sp-up);
+}
+[data-status-theme="editorial"] .ed-badge-warn {
+ background: var(--sp-warn-soft);
+ color: var(--sp-warn);
+}
+[data-status-theme="editorial"] .ed-badge-down {
+ background: var(--sp-down-soft);
+ color: var(--sp-down);
+}
+
+/* --- charts: flat rectangles, no glow --- */
+[data-status-theme="editorial"] .ed-heatmap {
+ margin-top: 16px;
+ display: grid;
+ grid-template-columns: repeat(25, 1fr);
+ gap: 2px;
+ height: 40px;
+}
+[data-status-theme="editorial"] .ed-heatmap-cell {
+ background: var(--sp-up);
+}
+[data-status-theme="editorial"] .ed-heatmap-cell.ed-fast {
+ background: var(--sp-up);
+}
+[data-status-theme="editorial"] .ed-heatmap-cell.ed-med {
+ background: color-mix(in srgb, var(--sp-up) 60%, var(--sp-bg));
+}
+[data-status-theme="editorial"] .ed-heatmap-cell.ed-slow {
+ background: var(--sp-warn);
+}
+[data-status-theme="editorial"] .ed-heatmap-cell.ed-down {
+ background: var(--sp-down);
+}
+[data-status-theme="editorial"] .ed-heatmap-cell.ed-empty {
+ background: var(--sp-border);
+ opacity: 0.5;
+}
+
+[data-status-theme="editorial"] .ed-histogram {
+ margin-top: 16px;
+ display: grid;
+ grid-template-columns: repeat(25, 1fr);
+ gap: 2px;
+ align-items: flex-end;
+ height: 40px;
+}
+[data-status-theme="editorial"] .ed-bar {
+ background: var(--sp-up);
+ min-height: 3px;
+}
+[data-status-theme="editorial"] .ed-bar.ed-down {
+ background: var(--sp-down);
+}
+[data-status-theme="editorial"] .ed-bar.ed-empty {
+ background: var(--sp-border);
+ opacity: 0.5;
+}
+[data-status-theme="editorial"] .ed-chart-stats {
+ font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
+ font-size: 11px;
+ color: var(--sp-text-muted);
+ font-variant-numeric: tabular-nums;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+}
+
+/* --- gauges: 3-column with serif value + 2px underline bar --- */
+[data-status-theme="editorial"] .ed-infra {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 24px;
+ margin-top: 20px;
+ font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
+}
+@media (max-width: 720px) {
+ [data-status-theme="editorial"] .ed-infra {
+ grid-template-columns: 1fr;
+ }
+}
+[data-status-theme="editorial"] .ed-infra-empty {
+ margin-top: 20px;
+ color: var(--sp-text-muted);
+ font-size: 13px;
+}
+[data-status-theme="editorial"] .ed-gauge-label {
+ font-size: 10px;
+ color: var(--sp-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.18em;
+ font-weight: 700;
+}
+[data-status-theme="editorial"] .ed-gauge-value {
+ font-size: 28px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ margin-top: 4px;
+ font-variant-numeric: tabular-nums;
+ font-family: Georgia, serif;
+ color: var(--sp-text);
+}
+[data-status-theme="editorial"] .ed-gauge-bar {
+ height: 2px;
+ background: var(--sp-border);
+ margin-top: 10px;
+}
+[data-status-theme="editorial"] .ed-gauge-fill {
+ display: block;
+ height: 100%;
+ background: var(--sp-up);
+ transition: width 0.8s;
+}
+[data-status-theme="editorial"] .ed-gauge-fill.ed-warm {
+ background: var(--sp-warn);
+}
+[data-status-theme="editorial"] .ed-gauge-fill.ed-hot {
+ background: var(--sp-down);
+}
+[data-status-theme="editorial"] .ed-gauge-sub {
+ font-size: 11px;
+ color: var(--sp-text-muted);
+ margin-top: 8px;
+ font-variant-numeric: tabular-nums;
+}
+
+/* --- footer: tiny uppercase tracked + underlined link --- */
+[data-status-theme="editorial"] .ed-footer {
+ text-align: center;
+ color: var(--sp-text-muted);
+ font-size: 11px;
+ margin-top: 56px;
+ text-transform: uppercase;
+ letter-spacing: 0.2em;
+ font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
+}
+[data-status-theme="editorial"] .ed-footer a {
+ color: var(--sp-text);
+ text-decoration: none;
+ border-bottom: 1px solid var(--sp-text);
+ padding-bottom: 2px;
+ font-weight: 700;
+}
diff --git a/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx
new file mode 100644
index 000000000..f853d90d6
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx
@@ -0,0 +1,179 @@
+import { useState } from "react";
+import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import { useTranslation } from "react-i18next";
+import type { Monitor } from "@/Types/Monitor";
+import type { StatusPage } from "@/Types/StatusPage";
+import { getMonitorTypeLabel } from "@/Types/StatusPage";
+import { ThemedHeatmap } from "../shared/ThemedHeatmap";
+import { ThemedHistogram } from "../shared/ThemedHistogram";
+import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
+import {
+ monitorBadgeTone,
+ monoFirstChar,
+ resolveOverallStatus,
+ statusBadgeKey,
+} from "../shared/overallStatus";
+import "./modern.css";
+
+type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
+
+interface Props {
+ statusPage: StatusPage;
+ monitors: StatusPageMonitor[];
+}
+
+const PREFIX = "mod";
+
+export const ModernStatusPage = ({ statusPage, monitors }: Props) => {
+ const { t } = useTranslation();
+ const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
+
+ const overall = resolveOverallStatus(monitors, t, { iconSize: 20 });
+ const logoSrc = statusPage.logo?.data
+ ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
+ : null;
+
+ return (
+
+
+
+ {logoSrc ? (
+
+ ) : (
+
+ {monoFirstChar(statusPage.companyName)}
+
+ )}
+ {statusPage.companyName}
+
+
+
+
+
+
+ {overall.message}
+
+ {t("pages.statusPages.statusBar.monitoringSummary", {
+ count: monitors.length,
+ })}
+
+
+ {overall.icon}
+
+
+ {statusPage.showCharts && (
+
+
+ setChartMode("heatmap")}
+ >
+ {t("pages.statusPages.monitorsList.chartTypeHeatmap")}
+
+ setChartMode("histogram")}
+ >
+ {t("pages.statusPages.monitorsList.chartTypeHistogram")}
+
+
+
+ )}
+
+
+ {monitors.map((monitor) => {
+ const isHardware = monitor.type === "hardware";
+ const showInfra = isHardware && statusPage.showInfrastructure !== false;
+ const showChart = !isHardware && statusPage.showCharts !== false;
+ const badgeTone = monitorBadgeTone(monitor.status);
+
+ return (
+
+
+
+ {monitor.name}
+
+
+ {getMonitorTypeLabel(monitor.type, t)}
+
+ {monitor.url && (
+
+ {monitor.url}
+
+ )}
+
+
+
+ {t(statusBadgeKey[monitor.status])}
+
+
+
+ {showInfra && (
+
+ )}
+ {showChart &&
+ (chartMode === "heatmap" ? (
+
+ ) : (
+
+ ))}
+
+ );
+ })}
+
+
+
+ {t("pages.statusPages.footer.poweredBy")}{" "}
+
+ Checkmate
+
+
+
+ );
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/modern/modern.css b/client/src/Pages/StatusPage/Status/themes/modern/modern.css
new file mode 100644
index 000000000..d51064600
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/modern/modern.css
@@ -0,0 +1,519 @@
+/*
+ * Modern theme — scoped to [data-status-theme="modern"].
+ * Port of /Users/gorkemcetin/Desktop/checkmate-status-mockups/02-modern.html.
+ * Signature traits: gradient logo, pulsing status dot, gradient border +
+ * radial glow on the hero, 16px rounded cards with strong shadow and
+ * staggered fade-in on mount, gradient-filled chart bars, 4-column
+ * gauge row with gradient backgrounds.
+ */
+
+[data-status-theme="modern"]:not([data-status-transparent]) {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ background: var(--sp-bg);
+}
+
+[data-status-theme="modern"] .mod-page {
+ flex: 1 0 auto;
+ max-width: 980px;
+ width: 100%;
+ margin: 0 auto;
+ padding: 56px 20px 80px;
+ font:
+ 14px/1.5 -apple-system,
+ BlinkMacSystemFont,
+ "Inter",
+ "Segoe UI",
+ Roboto,
+ sans-serif;
+ color: var(--sp-text);
+ -webkit-font-smoothing: antialiased;
+}
+
+/* --- header / brand --- */
+[data-status-theme="modern"] .mod-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 32px;
+}
+[data-status-theme="modern"] .mod-brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-weight: 700;
+ font-size: 15px;
+ letter-spacing: -0.01em;
+ color: var(--sp-text);
+}
+[data-status-theme="modern"] .mod-logo-grad {
+ width: 32px;
+ height: 32px;
+ border-radius: 10px;
+ background: linear-gradient(135deg, var(--sp-up), var(--sp-up-strong));
+ display: grid;
+ place-items: center;
+ color: #fff;
+ font-weight: 700;
+ font-size: 14px;
+ box-shadow: 0 4px 10px color-mix(in srgb, var(--sp-up) 40%, transparent);
+}
+[data-status-theme="modern"] .mod-logo-img {
+ max-height: 32px;
+ max-width: 120px;
+ object-fit: contain;
+}
+
+/* --- hero: clean flat card with pulsing dot (the signature motion) --- */
+[data-status-theme="modern"] .mod-hero {
+ position: relative;
+ border-radius: 20px;
+ padding: 28px 32px;
+ margin-bottom: 24px;
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ background: var(--sp-surface);
+ border: 1px solid var(--sp-border);
+ box-shadow:
+ 0 2px 4px rgba(16, 24, 40, 0.04),
+ 0 12px 32px rgba(16, 24, 40, 0.06);
+}
+[data-status-theme="modern"][data-status-mode="dark"] .mod-hero {
+ box-shadow:
+ 0 2px 6px rgba(0, 0, 0, 0.5),
+ 0 20px 40px rgba(0, 0, 0, 0.4);
+}
+[data-status-theme="modern"] .mod-pulse {
+ position: relative;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--sp-up);
+ flex-shrink: 0;
+}
+[data-status-theme="modern"] .mod-hero.mod-tone-warn .mod-pulse {
+ background: var(--sp-warn);
+}
+[data-status-theme="modern"] .mod-hero.mod-tone-down .mod-pulse {
+ background: var(--sp-down);
+}
+[data-status-theme="modern"] .mod-pulse::before,
+[data-status-theme="modern"] .mod-pulse::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ background: currentColor;
+ opacity: 0.5;
+ animation: mod-pulse 2s ease-out infinite;
+}
+[data-status-theme="modern"] .mod-pulse::after {
+ animation-delay: 1s;
+}
+[data-status-theme="modern"] .mod-pulse {
+ color: var(--sp-up);
+}
+[data-status-theme="modern"] .mod-hero.mod-tone-warn .mod-pulse {
+ color: var(--sp-warn);
+}
+[data-status-theme="modern"] .mod-hero.mod-tone-down .mod-pulse {
+ color: var(--sp-down);
+}
+@keyframes mod-pulse {
+ 0% {
+ transform: scale(1);
+ opacity: 0.5;
+ }
+ 100% {
+ transform: scale(2.4);
+ opacity: 0;
+ }
+}
+[data-status-theme="modern"] .mod-status-copy {
+ flex: 1;
+ min-width: 0;
+}
+[data-status-theme="modern"] .mod-hero-title {
+ margin: 0 0 4px;
+ font-size: 22px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ color: var(--sp-text);
+}
+[data-status-theme="modern"] .mod-hero-sub {
+ margin: 0;
+ color: var(--sp-text-muted);
+ font-size: 13px;
+}
+[data-status-theme="modern"] .mod-hero-icon {
+ color: var(--sp-text-muted);
+ display: flex;
+ align-items: center;
+}
+[data-status-theme="modern"] .mod-hero.mod-tone-up .mod-hero-icon {
+ color: var(--sp-up);
+}
+[data-status-theme="modern"] .mod-hero.mod-tone-warn .mod-hero-icon {
+ color: var(--sp-warn);
+}
+[data-status-theme="modern"] .mod-hero.mod-tone-down .mod-hero-icon {
+ color: var(--sp-down);
+}
+
+/* --- chart toggle (rounded segmented control) --- */
+[data-status-theme="modern"] .mod-chart-switch-wrap {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 14px;
+}
+[data-status-theme="modern"] .mod-chart-switch {
+ display: inline-flex;
+ border: 1px solid var(--sp-border);
+ border-radius: 999px;
+ background: var(--sp-surface);
+ padding: 3px;
+ gap: 2px;
+}
+[data-status-theme="modern"] .mod-chart-switch > button {
+ border: 0;
+ background: transparent;
+ font: inherit;
+ font-size: 11px;
+ padding: 6px 16px;
+ cursor: pointer;
+ color: var(--sp-text-muted);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ transition:
+ background 0.2s,
+ color 0.2s;
+ border-radius: 999px;
+}
+[data-status-theme="modern"] .mod-chart-switch > button:hover {
+ color: var(--sp-text);
+}
+[data-status-theme="modern"] .mod-chart-switch > button.mod-active {
+ color: var(--sp-up);
+ background: var(--sp-up-soft);
+}
+
+/* --- cards: 16px radius, strong shadow, staggered fade-in, hover lift --- */
+[data-status-theme="modern"] .mod-monitor-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+[data-status-theme="modern"] .mod-card {
+ background: var(--sp-surface);
+ border: 1px solid var(--sp-border);
+ border-radius: 16px;
+ box-shadow:
+ 0 2px 4px rgba(16, 24, 40, 0.04),
+ 0 12px 32px rgba(16, 24, 40, 0.06);
+ overflow: hidden;
+ transition:
+ transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1),
+ box-shadow 0.2s;
+ opacity: 0;
+ transform: translateY(8px);
+ animation: mod-fadein 0.5s forwards;
+}
+[data-status-theme="modern"][data-status-mode="dark"] .mod-card {
+ box-shadow:
+ 0 2px 6px rgba(0, 0, 0, 0.5),
+ 0 20px 40px rgba(0, 0, 0, 0.4);
+}
+[data-status-theme="modern"] .mod-card:nth-child(1) {
+ animation-delay: 0.05s;
+}
+[data-status-theme="modern"] .mod-card:nth-child(2) {
+ animation-delay: 0.1s;
+}
+[data-status-theme="modern"] .mod-card:nth-child(3) {
+ animation-delay: 0.15s;
+}
+[data-status-theme="modern"] .mod-card:nth-child(4) {
+ animation-delay: 0.2s;
+}
+[data-status-theme="modern"] .mod-card:nth-child(5) {
+ animation-delay: 0.25s;
+}
+[data-status-theme="modern"] .mod-card:nth-child(n + 6) {
+ animation-delay: 0.3s;
+}
+@keyframes mod-fadein {
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+[data-status-theme="modern"] .mod-card:hover {
+ transform: translateY(-2px);
+ box-shadow:
+ 0 4px 8px rgba(16, 24, 40, 0.05),
+ 0 16px 40px rgba(16, 24, 40, 0.08);
+}
+
+[data-status-theme="modern"] .mod-card-row {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ gap: 20px;
+ padding: 18px 24px;
+}
+[data-status-theme="modern"] .mod-card-left {
+ min-width: 0;
+}
+[data-status-theme="modern"] .mod-monitor-name {
+ font-weight: 600;
+ font-size: 15px;
+ letter-spacing: -0.005em;
+ color: var(--sp-text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+[data-status-theme="modern"] .mod-monitor-meta {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ margin-top: 6px;
+ flex-wrap: wrap;
+}
+[data-status-theme="modern"] .mod-pill {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--sp-text-muted);
+ background: var(--sp-bg);
+ padding: 3px 9px;
+ border-radius: 999px;
+ font-weight: 600;
+}
+[data-status-theme="modern"] .mod-pill-hardware {
+ color: #2563eb;
+ background: rgba(37, 99, 235, 0.1);
+}
+[data-status-theme="modern"][data-status-mode="dark"] .mod-pill-hardware {
+ color: #60a5fa;
+ background: rgba(96, 165, 250, 0.15);
+}
+[data-status-theme="modern"] .mod-monitor-url {
+ font-size: 12px;
+ color: var(--sp-text-muted);
+ font-family: ui-monospace, Menlo, monospace;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 280px;
+}
+
+/* --- badge: with inner dot --- */
+[data-status-theme="modern"] .mod-badge {
+ font-size: 11px;
+ font-weight: 700;
+ padding: 5px 12px;
+ border-radius: 999px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+}
+[data-status-theme="modern"] .mod-badge::before {
+ content: "";
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+}
+[data-status-theme="modern"] .mod-badge-up {
+ background: var(--sp-up-soft);
+ color: var(--sp-up);
+}
+[data-status-theme="modern"] .mod-badge-warn {
+ background: var(--sp-warn-soft);
+ color: var(--sp-warn);
+}
+[data-status-theme="modern"] .mod-badge-down {
+ background: var(--sp-down-soft);
+ color: var(--sp-down);
+}
+
+/* --- heatmap with gradient cells --- */
+[data-status-theme="modern"] .mod-heatmap {
+ padding: 0 24px 20px;
+ display: grid;
+ grid-template-columns: repeat(25, 1fr);
+ gap: 3px;
+ height: 46px;
+}
+[data-status-theme="modern"] .mod-heatmap-cell {
+ border-radius: 3px;
+ background: var(--sp-up);
+ transition: transform 0.15s;
+}
+[data-status-theme="modern"] .mod-heatmap-cell:hover {
+ transform: scaleY(1.2);
+}
+[data-status-theme="modern"] .mod-heatmap-cell.mod-fast {
+ background: linear-gradient(180deg, var(--sp-up), var(--sp-up-strong));
+}
+[data-status-theme="modern"] .mod-heatmap-cell.mod-med {
+ background: linear-gradient(
+ 180deg,
+ color-mix(in srgb, var(--sp-up) 70%, #ffffff 30%),
+ var(--sp-up)
+ );
+}
+[data-status-theme="modern"] .mod-heatmap-cell.mod-slow {
+ background: linear-gradient(180deg, var(--sp-warn), var(--sp-degraded));
+}
+[data-status-theme="modern"] .mod-heatmap-cell.mod-down {
+ background: linear-gradient(
+ 180deg,
+ var(--sp-down),
+ color-mix(in srgb, var(--sp-down) 70%, #000000 30%)
+ );
+}
+[data-status-theme="modern"] .mod-heatmap-cell.mod-empty {
+ background: var(--sp-border);
+ opacity: 0.4;
+}
+
+/* --- histogram with gradient bars --- */
+[data-status-theme="modern"] .mod-histogram {
+ padding: 0 24px;
+ display: grid;
+ grid-template-columns: repeat(25, 1fr);
+ gap: 3px;
+ align-items: flex-end;
+ height: 46px;
+}
+[data-status-theme="modern"] .mod-bar {
+ background: var(--sp-up);
+ border-radius: 3px;
+ min-height: 4px;
+ transition: transform 0.15s;
+}
+[data-status-theme="modern"] .mod-bar:hover {
+ transform: scaleY(1.08);
+}
+[data-status-theme="modern"] .mod-bar.mod-down {
+ background: var(--sp-down);
+}
+[data-status-theme="modern"] .mod-bar.mod-empty {
+ background: var(--sp-border);
+ opacity: 0.4;
+}
+[data-status-theme="modern"] .mod-chart-stats {
+ padding: 0 24px 20px;
+ font-size: 11px;
+ color: var(--sp-text-muted);
+ font-variant-numeric: tabular-nums;
+ font-weight: 600;
+}
+
+/* --- infra gauges: 3-col (4 if temp present), gradient surfaces --- */
+[data-status-theme="modern"] .mod-infra {
+ padding: 16px 24px 22px;
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 14px;
+}
+@media (max-width: 720px) {
+ [data-status-theme="modern"] .mod-infra {
+ grid-template-columns: 1fr;
+ }
+}
+[data-status-theme="modern"] .mod-infra-empty {
+ padding: 16px 24px 22px;
+ color: var(--sp-text-muted);
+ font-size: 13px;
+}
+[data-status-theme="modern"] .mod-gauge {
+ border: 1px solid var(--sp-border);
+ border-radius: 14px;
+ padding: 14px 16px;
+ background: linear-gradient(135deg, var(--sp-bg), var(--sp-surface));
+}
+[data-status-theme="modern"] .mod-gauge-label {
+ font-size: 10px;
+ color: var(--sp-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ font-weight: 700;
+}
+[data-status-theme="modern"] .mod-gauge-value {
+ font-size: 26px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ margin-top: 4px;
+ font-variant-numeric: tabular-nums;
+ color: var(--sp-text);
+}
+[data-status-theme="modern"] .mod-gauge-bar {
+ height: 6px;
+ background: var(--sp-border);
+ border-radius: 3px;
+ overflow: hidden;
+ margin-top: 10px;
+}
+[data-status-theme="modern"] .mod-gauge-fill {
+ display: block;
+ height: 100%;
+ background: linear-gradient(90deg, var(--sp-up), var(--sp-up-strong));
+ border-radius: 3px;
+ transition: width 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+[data-status-theme="modern"] .mod-gauge-fill.mod-warm {
+ background: linear-gradient(90deg, var(--sp-warn), var(--sp-degraded));
+}
+[data-status-theme="modern"] .mod-gauge-fill.mod-hot {
+ background: linear-gradient(
+ 90deg,
+ var(--sp-down),
+ color-mix(in srgb, var(--sp-down) 70%, #000000 30%)
+ );
+}
+[data-status-theme="modern"] .mod-gauge-sub {
+ font-size: 11px;
+ color: var(--sp-text-muted);
+ margin-top: 8px;
+ font-variant-numeric: tabular-nums;
+}
+
+/* --- footer --- */
+[data-status-theme="modern"] .mod-footer {
+ text-align: center;
+ color: var(--sp-text-muted);
+ font-size: 12px;
+ margin-top: 48px;
+}
+[data-status-theme="modern"] .mod-footer a {
+ color: var(--sp-up);
+ text-decoration: underline;
+ text-underline-offset: 3px;
+ font-weight: 700;
+}
+[data-status-theme="modern"] .mod-footer a:hover {
+ color: var(--sp-up-strong);
+}
+
+/* Reduced motion */
+@media (prefers-reduced-motion: reduce) {
+ [data-status-theme="modern"] .mod-card {
+ animation: none;
+ opacity: 1;
+ transform: none;
+ }
+ [data-status-theme="modern"] .mod-pulse::before,
+ [data-status-theme="modern"] .mod-pulse::after {
+ animation: none;
+ }
+}
diff --git a/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx
new file mode 100644
index 000000000..5be6eab21
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx
@@ -0,0 +1,179 @@
+import { useState } from "react";
+import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import { useTranslation } from "react-i18next";
+import type { Monitor } from "@/Types/Monitor";
+import type { StatusPage } from "@/Types/StatusPage";
+import { getMonitorTypeLabel } from "@/Types/StatusPage";
+import { ThemedHeatmap } from "../shared/ThemedHeatmap";
+import { ThemedHistogram } from "../shared/ThemedHistogram";
+import { ThemedInfrastructure } from "../shared/ThemedInfrastructure";
+import {
+ monitorBadgeTone,
+ monoFirstChar,
+ resolveOverallStatus,
+ statusBadgeKey,
+} from "../shared/overallStatus";
+import "./refined.css";
+
+type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
+
+interface Props {
+ statusPage: StatusPage;
+ monitors: StatusPageMonitor[];
+}
+
+const PREFIX = "rf";
+
+export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
+ const { t } = useTranslation();
+ const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
+
+ const overall = resolveOverallStatus(monitors, t);
+ const logoSrc = statusPage.logo?.data
+ ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
+ : null;
+
+ return (
+
+
+
+ {logoSrc ? (
+
+ ) : (
+
+ {monoFirstChar(statusPage.companyName)}
+
+ )}
+ {statusPage.companyName}
+
+
+
+
+
+
+ {overall.message}
+
+ {t("pages.statusPages.statusBar.monitoringSummary", {
+ count: monitors.length,
+ })}
+
+
+ {overall.icon}
+
+
+ {statusPage.showCharts && (
+
+
+ setChartMode("heatmap")}
+ >
+ {t("pages.statusPages.monitorsList.chartTypeHeatmap")}
+
+ setChartMode("histogram")}
+ >
+ {t("pages.statusPages.monitorsList.chartTypeHistogram")}
+
+
+
+ )}
+
+
+ {monitors.map((monitor) => {
+ const isHardware = monitor.type === "hardware";
+ const showInfra = isHardware && statusPage.showInfrastructure !== false;
+ const showChart = !isHardware && statusPage.showCharts !== false;
+ const badgeTone = monitorBadgeTone(monitor.status);
+
+ return (
+
+
+
+ {monitor.name}
+
+
+ {getMonitorTypeLabel(monitor.type, t)}
+
+ {monitor.url && (
+
+ {monitor.url}
+
+ )}
+
+
+
+ {t(statusBadgeKey[monitor.status])}
+
+
+
+ {showInfra && (
+
+ )}
+ {showChart &&
+ (chartMode === "heatmap" ? (
+
+ ) : (
+
+ ))}
+
+ );
+ })}
+
+
+
+ {t("pages.statusPages.footer.poweredBy")}{" "}
+
+ Checkmate
+
+
+
+ );
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/refined/refined.css b/client/src/Pages/StatusPage/Status/themes/refined/refined.css
new file mode 100644
index 000000000..ac1dee862
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/refined/refined.css
@@ -0,0 +1,420 @@
+/*
+ * Refined theme — scoped to [data-status-theme="refined"].
+ * Port of /Users/gorkemcetin/Desktop/checkmate-status-mockups/01-refined.html.
+ * Colour tokens come from the provider's inline CSS vars (see tokens.ts).
+ */
+
+[data-status-theme="refined"]:not([data-status-transparent]) {
+ /* On the public route the wrapper paints its own bg and fills the
+ viewport. Inside a BrowserFrame (admin preview) it stays transparent
+ so the frame's rounded corners don't bleed the wrapper's square
+ corners past the clip. */
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+[data-status-theme="refined"] .rf-page {
+ flex: 1 0 auto;
+ max-width: 960px;
+ width: 100%;
+ margin: 0 auto;
+ padding: 48px 20px 80px;
+ font:
+ 14px/1.5 -apple-system,
+ BlinkMacSystemFont,
+ "Inter",
+ "Segoe UI",
+ Roboto,
+ sans-serif;
+ color: var(--sp-text);
+ -webkit-font-smoothing: antialiased;
+}
+
+/* --- header --- */
+[data-status-theme="refined"] .rf-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 28px;
+}
+[data-status-theme="refined"] .rf-brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ color: var(--sp-text);
+}
+[data-status-theme="refined"] .rf-logo-mono {
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ background: var(--sp-up);
+ display: grid;
+ place-items: center;
+ color: #fff;
+ font-weight: 700;
+ font-size: 13px;
+}
+[data-status-theme="refined"] .rf-logo-img {
+ max-height: 32px;
+ max-width: 120px;
+ object-fit: contain;
+}
+[data-status-theme="refined"] .rf-company {
+ font-size: 14px;
+}
+
+/* --- hero status bar --- */
+[data-status-theme="refined"] .rf-hero {
+ background: var(--sp-surface);
+ border: 1px solid var(--sp-border);
+ border-radius: var(--sp-radius);
+ padding: 22px 24px;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ box-shadow: var(
+ --sp-shadow,
+ 0 1px 2px rgba(10, 16, 32, 0.06),
+ 0 6px 18px rgba(10, 16, 32, 0.08)
+ );
+ margin-bottom: 20px;
+}
+[data-status-theme="refined"] .rf-status-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--sp-up);
+ box-shadow: 0 0 0 4px var(--sp-up-soft);
+ flex-shrink: 0;
+}
+[data-status-theme="refined"] .rf-hero.rf-tone-warn .rf-status-dot {
+ background: var(--sp-warn);
+ box-shadow: 0 0 0 4px var(--sp-warn-soft);
+}
+[data-status-theme="refined"] .rf-hero.rf-tone-down .rf-status-dot {
+ background: var(--sp-down);
+ box-shadow: 0 0 0 4px var(--sp-down-soft);
+}
+[data-status-theme="refined"] .rf-status-copy {
+ flex: 1;
+ min-width: 0;
+}
+[data-status-theme="refined"] .rf-hero-title {
+ margin: 0 0 2px;
+ font-size: 17px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ color: var(--sp-text);
+}
+[data-status-theme="refined"] .rf-hero-sub {
+ margin: 0;
+ color: var(--sp-text-muted);
+ font-size: 13px;
+}
+[data-status-theme="refined"] .rf-hero-icon {
+ color: var(--sp-text-muted);
+ display: flex;
+ align-items: center;
+}
+[data-status-theme="refined"] .rf-hero.rf-tone-up .rf-hero-icon {
+ color: var(--sp-up);
+}
+[data-status-theme="refined"] .rf-hero.rf-tone-warn .rf-hero-icon {
+ color: var(--sp-warn);
+}
+[data-status-theme="refined"] .rf-hero.rf-tone-down .rf-hero-icon {
+ color: var(--sp-down);
+}
+
+/* --- chart switch (segmented control) --- */
+[data-status-theme="refined"] .rf-chart-switch-wrap {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 12px;
+}
+[data-status-theme="refined"] .rf-chart-switch {
+ display: inline-flex;
+ border: 1px solid var(--sp-border);
+ border-radius: 8px;
+ background: var(--sp-surface);
+ padding: 3px;
+ gap: 2px;
+}
+[data-status-theme="refined"] .rf-chart-switch > button {
+ border: 0;
+ background: transparent;
+ font:
+ inherit,
+ -apple-system,
+ sans-serif;
+ font-size: 11px;
+ padding: 5px 14px;
+ cursor: pointer;
+ color: var(--sp-text-muted);
+ border-radius: 5px;
+ transition:
+ background 0.15s ease,
+ color 0.15s ease;
+ font-weight: 500;
+}
+[data-status-theme="refined"] .rf-chart-switch > button:hover {
+ color: var(--sp-text);
+}
+[data-status-theme="refined"] .rf-chart-switch > button.rf-active {
+ color: var(--sp-up);
+ background: var(--sp-up-soft);
+ font-weight: 600;
+}
+
+/* --- monitor card list --- */
+[data-status-theme="refined"] .rf-monitor-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+[data-status-theme="refined"] .rf-card {
+ background: var(--sp-surface);
+ border: 1px solid var(--sp-border);
+ border-radius: var(--sp-radius);
+ box-shadow: var(
+ --sp-shadow,
+ 0 1px 2px rgba(10, 16, 32, 0.06),
+ 0 6px 18px rgba(10, 16, 32, 0.08)
+ );
+ overflow: hidden;
+ position: relative;
+ transition:
+ transform 0.15s,
+ box-shadow 0.15s;
+}
+[data-status-theme="refined"] .rf-card:hover {
+ transform: translateY(-1px);
+ box-shadow:
+ 0 2px 4px rgba(16, 24, 40, 0.06),
+ 0 10px 24px rgba(16, 24, 40, 0.06);
+}
+
+[data-status-theme="refined"] .rf-card-row {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ gap: 16px;
+ padding: 16px 20px;
+}
+[data-status-theme="refined"] .rf-card-left {
+ min-width: 0;
+}
+[data-status-theme="refined"] .rf-monitor-name {
+ font-weight: 600;
+ font-size: 14px;
+ letter-spacing: -0.005em;
+ color: var(--sp-text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+[data-status-theme="refined"] .rf-monitor-meta {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ margin-top: 4px;
+ flex-wrap: wrap;
+}
+[data-status-theme="refined"] .rf-pill {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--sp-text-muted);
+ border: 1px solid var(--sp-border);
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-weight: 600;
+}
+[data-status-theme="refined"] .rf-pill-hardware {
+ color: #1d4ed8;
+ border-color: rgba(29, 78, 216, 0.3);
+ background: rgba(29, 78, 216, 0.06);
+}
+[data-status-theme="refined"][data-status-mode="dark"] .rf-pill-hardware {
+ color: #60a5fa;
+ border-color: rgba(96, 165, 250, 0.3);
+ background: rgba(96, 165, 250, 0.1);
+}
+[data-status-theme="refined"] .rf-monitor-url {
+ font-size: 12px;
+ color: var(--sp-text-muted);
+ font-family: ui-monospace, Menlo, monospace;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 280px;
+}
+
+[data-status-theme="refined"] .rf-badge {
+ font-size: 11px;
+ font-weight: 600;
+ padding: 4px 10px;
+ border-radius: 999px;
+ white-space: nowrap;
+}
+[data-status-theme="refined"] .rf-badge-up {
+ background: var(--sp-up-soft);
+ color: var(--sp-up);
+}
+[data-status-theme="refined"] .rf-badge-warn {
+ background: var(--sp-warn-soft);
+ color: var(--sp-warn);
+}
+[data-status-theme="refined"] .rf-badge-down {
+ background: var(--sp-down-soft);
+ color: var(--sp-down);
+}
+
+/* --- heatmap --- */
+[data-status-theme="refined"] .rf-heatmap {
+ padding: 0 20px 16px;
+ display: grid;
+ grid-template-columns: repeat(25, 1fr);
+ gap: 3px;
+ height: 42px;
+}
+[data-status-theme="refined"] .rf-heatmap-cell {
+ border-radius: 2px;
+ background: var(--sp-up);
+ transition: transform 0.15s;
+}
+[data-status-theme="refined"] .rf-heatmap-cell:hover {
+ transform: scaleY(1.15);
+}
+[data-status-theme="refined"] .rf-heatmap-cell.rf-fast {
+ background: var(--sp-up);
+}
+[data-status-theme="refined"] .rf-heatmap-cell.rf-med {
+ background: color-mix(in srgb, var(--sp-up) 60%, #ffffff 40%);
+}
+[data-status-theme="refined"] .rf-heatmap-cell.rf-slow {
+ background: var(--sp-warn);
+}
+[data-status-theme="refined"] .rf-heatmap-cell.rf-down {
+ background: var(--sp-down);
+}
+[data-status-theme="refined"] .rf-heatmap-cell.rf-empty {
+ background: var(--sp-border);
+ opacity: 0.4;
+}
+
+/* --- histogram --- */
+[data-status-theme="refined"] .rf-histogram {
+ padding: 0 20px;
+ display: grid;
+ grid-template-columns: repeat(25, 1fr);
+ gap: 3px;
+ align-items: flex-end;
+ height: 42px;
+}
+[data-status-theme="refined"] .rf-bar {
+ background: var(--sp-up);
+ border-radius: 2px;
+ min-height: 3px;
+}
+[data-status-theme="refined"] .rf-bar.rf-down {
+ background: var(--sp-down);
+}
+[data-status-theme="refined"] .rf-bar.rf-empty {
+ background: var(--sp-border);
+ opacity: 0.4;
+}
+[data-status-theme="refined"] .rf-chart-stats {
+ padding: 0 20px 16px;
+ font-size: 11px;
+ color: var(--sp-text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+/* --- infrastructure gauges --- */
+[data-status-theme="refined"] .rf-infra {
+ padding: 14px 20px 18px;
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+}
+@media (max-width: 720px) {
+ [data-status-theme="refined"] .rf-infra {
+ grid-template-columns: 1fr;
+ }
+}
+[data-status-theme="refined"] .rf-infra-empty {
+ padding: 14px 20px 18px;
+ color: var(--sp-text-muted);
+ font-size: 13px;
+}
+[data-status-theme="refined"] .rf-gauge {
+ border: 1px solid var(--sp-border);
+ border-radius: 10px;
+ padding: 12px 14px;
+ background: var(--sp-bg);
+}
+[data-status-theme="refined"] .rf-gauge-label {
+ font-size: 11px;
+ color: var(--sp-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ font-weight: 600;
+}
+[data-status-theme="refined"] .rf-gauge-value {
+ font-size: 20px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ margin-top: 2px;
+ font-variant-numeric: tabular-nums;
+ color: var(--sp-text);
+}
+[data-status-theme="refined"] .rf-gauge-bar {
+ height: 4px;
+ background: var(--sp-border);
+ border-radius: 2px;
+ overflow: hidden;
+ margin-top: 8px;
+}
+[data-status-theme="refined"] .rf-gauge-fill {
+ display: block;
+ height: 100%;
+ background: var(--sp-up);
+ border-radius: 2px;
+ transition: width 0.6s;
+}
+[data-status-theme="refined"] .rf-gauge-fill.rf-warm {
+ background: var(--sp-warn);
+}
+[data-status-theme="refined"] .rf-gauge-fill.rf-hot {
+ background: var(--sp-down);
+}
+[data-status-theme="refined"] .rf-gauge-sub {
+ font-size: 11px;
+ color: var(--sp-text-muted);
+ margin-top: 6px;
+ font-variant-numeric: tabular-nums;
+}
+
+/* --- footer --- */
+[data-status-theme="refined"] .rf-footer {
+ text-align: center;
+ color: var(--sp-text-muted);
+ font-size: 12px;
+ margin-top: 40px;
+}
+[data-status-theme="refined"] .rf-footer a {
+ color: var(--sp-up);
+ text-decoration: underline;
+ text-underline-offset: 3px;
+ font-weight: 600;
+}
+[data-status-theme="refined"] .rf-footer a:hover {
+ color: var(--sp-up-strong, var(--sp-up));
+}
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
new file mode 100644
index 000000000..735f02fa8
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
@@ -0,0 +1,83 @@
+import Tooltip from "@mui/material/Tooltip";
+import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { useTranslation } from "react-i18next";
+import type { CheckSnapshot } from "@/Types/Check";
+import { formatCheckTimestamp } from "./timeFormat";
+
+const CELLS = 25;
+
+interface Props {
+ checks: CheckSnapshot[];
+ classPrefix: string;
+}
+
+const classify = (check: CheckSnapshot): "fast" | "med" | "slow" | "down" => {
+ if (!check.status) return "down";
+ const rt = check.responseTime ?? 0;
+ if (rt > 500) return "slow";
+ if (rt > 250) return "med";
+ return "fast";
+};
+
+export const ThemedHeatmap = ({ checks, classPrefix }: Props) => {
+ const { t } = useTranslation();
+
+ const source = checks.slice(0, CELLS).reverse();
+ const padded: (CheckSnapshot | null)[] = [
+ ...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
+ ...source,
+ ];
+
+ return (
+
+ {padded.map((check, i) => {
+ if (!check) {
+ return (
+
+ );
+ }
+ const cls = classify(check);
+ const tooltipContent = (
+
+
+ {check.status
+ ? `${check.responseTime} ms`
+ : t("pages.statusPages.monitorsList.chart.downTooltip")}
+
+
+ {formatCheckTimestamp(check.createdAt as unknown as string)}
+
+
+ );
+ return (
+
+
+
+ );
+ })}
+
+ );
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
new file mode 100644
index 000000000..83a63d01c
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
@@ -0,0 +1,99 @@
+import { useMemo } from "react";
+import Tooltip from "@mui/material/Tooltip";
+import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { useTranslation } from "react-i18next";
+import type { CheckSnapshot } from "@/Types/Check";
+import { formatCheckTimestamp } from "./timeFormat";
+
+const CELLS = 25;
+const MIN_HEIGHT_PCT = 6;
+
+interface Props {
+ checks: CheckSnapshot[];
+ classPrefix: string;
+ statsGap?: number;
+}
+
+const tone = (check: CheckSnapshot): "up" | "down" => (check.status ? "up" : "down");
+
+export const ThemedHistogram = ({ checks, classPrefix, statsGap = 1 }: Props) => {
+ const { t } = useTranslation();
+
+ const { padded, max, avg, peak } = useMemo(() => {
+ const source = checks.slice(0, CELLS).reverse();
+ const out: (CheckSnapshot | null)[] = [
+ ...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null),
+ ...source,
+ ];
+ const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0);
+ const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1;
+ const avgRt = valid.length
+ ? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length)
+ : 0;
+ return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 };
+ }, [checks]);
+
+ return (
+
+
+ {padded.map((check, i) => {
+ if (!check) {
+ return (
+
+ );
+ }
+ const height = Math.max(
+ MIN_HEIGHT_PCT,
+ Math.round((check.responseTime / max) * 100)
+ );
+ const tooltipContent = (
+
+
+ {check.status
+ ? `${check.responseTime} ms`
+ : t("pages.statusPages.monitorsList.chart.downTooltip")}
+
+
+ {formatCheckTimestamp(check.createdAt as unknown as string)}
+
+
+ );
+ return (
+
+
+
+ );
+ })}
+
+
+ {t("pages.statusPages.monitorsList.chart.avg", { value: avg })}
+ {t("pages.statusPages.monitorsList.chart.max", { value: peak })}
+
+
+ );
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx
new file mode 100644
index 000000000..f465ece91
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx
@@ -0,0 +1,102 @@
+import Box from "@mui/material/Box";
+import prettyBytes from "pretty-bytes";
+import { useTranslation } from "react-i18next";
+import type { Monitor } from "@/Types/Monitor";
+
+interface Props {
+ monitor: Monitor & { checks?: Monitor["recentChecks"] };
+ classPrefix: string;
+}
+
+interface Gauge {
+ key: string;
+ label: string;
+ value: number;
+ sub: string;
+}
+
+const PCT = 100;
+
+export const ThemedInfrastructure = ({ monitor, classPrefix }: Props) => {
+ const { t } = useTranslation();
+ const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
+ if (!latest) {
+ return (
+
+ {t("pages.statusPages.monitorsList.noData")}
+
+ );
+ }
+
+ const gauges: Gauge[] = [];
+
+ if (typeof latest.cpu?.usage_percent === "number") {
+ const pct = latest.cpu.usage_percent * PCT;
+ gauges.push({
+ key: "cpu",
+ label: t("pages.statusPages.monitorsList.infrastructure.cpu"),
+ value: pct,
+ sub: `${pct.toFixed(0)}%`,
+ });
+ }
+
+ if (
+ typeof latest.memory?.usage_percent === "number" &&
+ typeof latest.memory?.used_bytes === "number" &&
+ typeof latest.memory?.total_bytes === "number"
+ ) {
+ gauges.push({
+ key: "memory",
+ label: t("pages.statusPages.monitorsList.infrastructure.memory"),
+ value: latest.memory.usage_percent * PCT,
+ sub: `${prettyBytes(latest.memory.used_bytes)} / ${prettyBytes(latest.memory.total_bytes)}`,
+ });
+ }
+
+ if (latest.disk && latest.disk.length > 0) {
+ const disks = latest.disk;
+ const avg =
+ (disks.reduce((acc, d) => acc + (d?.usage_percent ?? 0), 0) / disks.length) * PCT;
+ const used = disks.reduce((acc, d) => acc + (d?.used_bytes ?? 0), 0);
+ const total = disks.reduce((acc, d) => acc + (d?.total_bytes ?? 0), 0);
+ gauges.push({
+ key: "disk",
+ label: t("pages.statusPages.monitorsList.infrastructure.disk"),
+ value: avg,
+ sub: `${prettyBytes(used)} / ${prettyBytes(total)}`,
+ });
+ }
+
+ if (gauges.length === 0) {
+ return (
+
+ {t("pages.statusPages.monitorsList.noData")}
+
+ );
+ }
+
+ return (
+
+ {gauges.map((g) => {
+ const heatClass =
+ g.value > 85 ? `${classPrefix}-hot` : g.value > 70 ? `${classPrefix}-warm` : "";
+ return (
+
+ {g.label}
+ {g.value.toFixed(0)}%
+
+
+
+ {g.sub}
+
+ );
+ })}
+
+ );
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/overallStatus.tsx b/client/src/Pages/StatusPage/Status/themes/shared/overallStatus.tsx
new file mode 100644
index 000000000..3318cb3f3
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/shared/overallStatus.tsx
@@ -0,0 +1,160 @@
+import type { ReactNode } from "react";
+import {
+ AlertTriangle,
+ CircleCheck,
+ CircleX,
+ Loader,
+ PauseCircle,
+ ShieldAlert,
+ Wrench,
+} from "lucide-react";
+import type { Monitor, MonitorStatus } from "@/Types/Monitor";
+
+export type OverallTone = "up" | "warn" | "down";
+
+export interface OverallStatus {
+ tone: OverallTone;
+ message: string;
+ icon: ReactNode;
+}
+
+type StatusPageMonitor = Pick;
+
+interface Options {
+ iconSize?: number;
+ // Optional override for the `allUp` message key (editorial uses a more
+ // formal sentence).
+ allUpKey?: string;
+}
+
+export const resolveOverallStatus = (
+ monitors: StatusPageMonitor[],
+ t: (key: string) => string,
+ options: Options = {}
+): OverallStatus => {
+ const size = options.iconSize ?? 18;
+ const allUpMessage = t(options.allUpKey ?? "pages.statusPages.statusBar.allUp");
+
+ if (monitors.length === 0) {
+ return {
+ tone: "warn",
+ message: t("pages.statusPages.statusBar.noMonitors"),
+ icon: ,
+ };
+ }
+
+ const allOf = (...statuses: MonitorStatus[]) =>
+ monitors.every((m) => statuses.includes(m.status));
+ const someOf = (...statuses: MonitorStatus[]) =>
+ monitors.some((m) => statuses.includes(m.status));
+ const noneOf = (...statuses: MonitorStatus[]) =>
+ monitors.every((m) => !statuses.includes(m.status));
+
+ if (allOf("up")) {
+ return { tone: "up", message: allUpMessage, icon: };
+ }
+ if (allOf("breached")) {
+ return {
+ tone: "down",
+ message: t("pages.statusPages.statusBar.allBreached"),
+ icon: ,
+ };
+ }
+ if (allOf("maintenance")) {
+ return {
+ tone: "warn",
+ message: t("pages.statusPages.statusBar.allMaintenance"),
+ icon: ,
+ };
+ }
+ if (allOf("down")) {
+ return {
+ tone: "down",
+ message: t("pages.statusPages.statusBar.allDown"),
+ icon: ,
+ };
+ }
+ if (allOf("paused")) {
+ return {
+ tone: "warn",
+ message: t("pages.statusPages.statusBar.allPaused"),
+ icon: ,
+ };
+ }
+ if (allOf("initializing")) {
+ return {
+ tone: "warn",
+ message: t("pages.statusPages.statusBar.allInitializing"),
+ icon: ,
+ };
+ }
+ if (someOf("breached") && someOf("down")) {
+ return {
+ tone: "down",
+ message: t("pages.statusPages.statusBar.breachedAndDown"),
+ icon: ,
+ };
+ }
+ if (someOf("breached")) {
+ return {
+ tone: "down",
+ message: t("pages.statusPages.statusBar.breached"),
+ icon: ,
+ };
+ }
+ if (someOf("maintenance") && someOf("down")) {
+ return {
+ tone: "down",
+ message: t("pages.statusPages.statusBar.maintenanceAndDown"),
+ icon: ,
+ };
+ }
+ if (someOf("maintenance") && noneOf("down")) {
+ return {
+ tone: "warn",
+ message: t("pages.statusPages.statusBar.maintenance"),
+ icon: ,
+ };
+ }
+ if (someOf("down")) {
+ return {
+ tone: "warn",
+ message: t("pages.statusPages.statusBar.degraded"),
+ icon: ,
+ };
+ }
+ if (someOf("paused")) {
+ return {
+ tone: "warn",
+ message: t("pages.statusPages.statusBar.partiallyPaused"),
+ icon: ,
+ };
+ }
+ if (someOf("initializing")) {
+ return {
+ tone: "up",
+ message: t("pages.statusPages.statusBar.initializing"),
+ icon: ,
+ };
+ }
+ return {
+ tone: "warn",
+ message: t("pages.statusPages.statusBar.unknown"),
+ icon: ,
+ };
+};
+
+export const statusBadgeKey: Record = {
+ up: "pages.statusPages.monitorsList.status.up",
+ down: "pages.statusPages.monitorsList.status.down",
+ breached: "pages.statusPages.monitorsList.status.breached",
+ maintenance: "pages.statusPages.monitorsList.status.maintenance",
+ paused: "pages.statusPages.monitorsList.status.paused",
+ initializing: "pages.statusPages.monitorsList.status.initializing",
+};
+
+export const monoFirstChar = (s?: string): string =>
+ (s?.trim().charAt(0) || "?").toUpperCase();
+
+export const monitorBadgeTone = (status: MonitorStatus): OverallTone =>
+ status === "up" ? "up" : status === "down" || status === "breached" ? "down" : "warn";
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/timeFormat.ts b/client/src/Pages/StatusPage/Status/themes/shared/timeFormat.ts
new file mode 100644
index 000000000..7e313471c
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/shared/timeFormat.ts
@@ -0,0 +1,12 @@
+export const formatCheckTimestamp = (iso?: string): string => {
+ if (!iso) return "—";
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return "—";
+ return d.toLocaleString(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ month: "short",
+ day: "numeric",
+ });
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/theme-overrides.css b/client/src/Pages/StatusPage/Status/themes/theme-overrides.css
new file mode 100644
index 000000000..b66257d18
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/theme-overrides.css
@@ -0,0 +1,156 @@
+/*
+ * Scoped styling for the public status page themes.
+ * Every selector is prefixed with [data-status-theme="..."] so these styles
+ * only apply inside the StatusPageThemeProvider subtree.
+ *
+ * Colour tokens come from CSS custom properties set by the provider
+ * (see tokens.ts → toCssVars). This file adds structural and surface rules
+ * that a plain token swap can't express.
+ */
+
+/* --- shared: push tokens onto MUI surfaces inside the themed tree --- */
+[data-status-theme] {
+ color: var(--sp-text);
+}
+
+[data-status-theme] .sp-card,
+[data-status-theme] .MuiBox-root.sp-card {
+ background-color: var(--sp-surface) !important;
+ border-color: var(--sp-border) !important;
+ border-radius: var(--sp-radius) !important;
+ color: var(--sp-text);
+}
+
+[data-status-theme] .sp-heading {
+ color: var(--sp-text);
+}
+
+[data-status-theme] .sp-badge {
+ background-color: var(--sp-up-soft) !important;
+ color: var(--sp-up) !important;
+ border-radius: 999px;
+ padding: 2px 10px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+}
+
+/* Status bar / hero */
+[data-status-theme] .sp-hero {
+ border-radius: var(--sp-radius);
+}
+
+/* --- refined: soft shadow, small accent on card --- */
+[data-status-theme="refined"] .sp-card {
+ box-shadow:
+ 0 1px 2px rgba(10, 16, 32, 0.06),
+ 0 6px 18px rgba(10, 16, 32, 0.08) !important;
+}
+[data-status-theme="refined"] .sp-hero-title {
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+
+/* --- modern: stronger shadow, larger radius, pulse dot + staggered fade --- */
+[data-status-theme="modern"] .sp-card {
+ box-shadow:
+ 0 2px 4px rgba(16, 24, 40, 0.04),
+ 0 12px 32px rgba(16, 24, 40, 0.06) !important;
+ border-radius: 16px !important;
+ opacity: 0;
+ transform: translateY(8px);
+ animation: sp-card-fadein 0.5s forwards;
+}
+[data-status-theme="modern"] .sp-card:nth-of-type(1) {
+ animation-delay: 0.05s;
+}
+[data-status-theme="modern"] .sp-card:nth-of-type(2) {
+ animation-delay: 0.1s;
+}
+[data-status-theme="modern"] .sp-card:nth-of-type(3) {
+ animation-delay: 0.15s;
+}
+[data-status-theme="modern"] .sp-card:nth-of-type(4) {
+ animation-delay: 0.2s;
+}
+[data-status-theme="modern"] .sp-card:nth-of-type(5) {
+ animation-delay: 0.25s;
+}
+[data-status-theme="modern"] .sp-card:nth-of-type(n + 6) {
+ animation-delay: 0.3s;
+}
+@keyframes sp-card-fadein {
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+[data-status-theme="modern"] .sp-hero-title {
+ font-size: 22px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+}
+
+/* --- bold: large type, conic-gradient hero accent when no logo --- */
+[data-status-theme="bold"] .sp-hero {
+ background:
+ linear-gradient(135deg, var(--sp-up-soft), transparent 60%), var(--sp-surface) !important;
+ border: 1px solid var(--sp-border) !important;
+}
+[data-status-theme="bold"] .sp-hero-title {
+ font-size: 32px;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ line-height: 1.1;
+ color: var(--sp-text) !important;
+}
+[data-status-theme="bold"] .sp-card {
+ border-radius: 18px !important;
+ border: 1px solid var(--sp-border) !important;
+}
+
+/* --- editorial: serif headings, hairline dividers instead of cards --- */
+[data-status-theme="editorial"] .sp-heading {
+ font-family: Georgia, "Iowan Old Style", "Palatino Linotype", serif;
+ letter-spacing: -0.01em;
+}
+[data-status-theme="editorial"] .sp-card {
+ background-color: transparent !important;
+ border: 0 !important;
+ border-bottom: 1px solid var(--sp-border) !important;
+ border-radius: 0 !important;
+ box-shadow: none !important;
+ padding-left: 0 !important;
+ padding-right: 0 !important;
+}
+[data-status-theme="editorial"] .sp-card:last-of-type {
+ border-bottom: 0 !important;
+}
+[data-status-theme="editorial"] .sp-hero {
+ background: transparent !important;
+ color: var(--sp-text) !important;
+ border-bottom: 2px solid var(--sp-text) !important;
+ border-radius: 0 !important;
+ box-shadow: none !important;
+}
+[data-status-theme="editorial"] .sp-hero-title {
+ font-family: Georgia, "Iowan Old Style", "Palatino Linotype", serif;
+ font-weight: 700;
+}
+[data-status-theme="editorial"] .sp-badge {
+ background-color: transparent !important;
+ color: var(--sp-text-muted) !important;
+ border: 1px solid var(--sp-border) !important;
+ border-radius: 0 !important;
+ text-transform: uppercase;
+ font-size: 10px;
+ letter-spacing: 0.12em;
+}
+
+/* Reduce motion */
+@media (prefers-reduced-motion: reduce) {
+ [data-status-theme] .sp-card {
+ animation: none;
+ opacity: 1;
+ transform: none;
+ }
+}
diff --git a/client/src/Pages/StatusPage/Status/themes/tokens.ts b/client/src/Pages/StatusPage/Status/themes/tokens.ts
new file mode 100644
index 000000000..8b0517836
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/tokens.ts
@@ -0,0 +1,218 @@
+import type { StatusPageTheme } from "@/Types/StatusPage";
+
+export interface StatusPageThemeTokens {
+ bg: string;
+ surface: string;
+ border: string;
+ text: string;
+ textMuted: string;
+ up: string;
+ upStrong: string;
+ upSoft: string;
+ degraded: string;
+ degradedSoft: string;
+ down: string;
+ downSoft: string;
+ warn: string;
+ warnSoft: string;
+ radius: string;
+ fontFamily?: string;
+ headingFontFamily?: string;
+ headingWeight?: number;
+ headingLetterSpacing?: string;
+ chartBarRadius?: string;
+ pulseStatusDot?: boolean;
+ staggeredCardFadeIn?: boolean;
+ conicLogo?: boolean;
+ heroSize?: "default" | "large";
+ cardStyle?: "card" | "hairline";
+}
+
+type ThemeVariants = { light: StatusPageThemeTokens; dark: StatusPageThemeTokens };
+
+const refined: ThemeVariants = {
+ light: {
+ bg: "#f4f6fa",
+ surface: "#ffffff",
+ border: "#dce1ea",
+ text: "#0a1020",
+ textMuted: "#4b5768",
+ up: "#0f8a6d",
+ upStrong: "#0c7359",
+ upSoft: "#d6f1e6",
+ degraded: "#c2630a",
+ degradedSoft: "#fde7bf",
+ down: "#d11f2f",
+ downSoft: "#fdd9dc",
+ warn: "#e08a0b",
+ warnSoft: "#fdebc5",
+ radius: "12px",
+ },
+ dark: {
+ bg: "#070b11",
+ surface: "#131a24",
+ border: "#253142",
+ text: "#f1f5fb",
+ textMuted: "#9aa7bd",
+ up: "#2fd7a2",
+ upStrong: "#1fb487",
+ upSoft: "rgba(47,215,162,0.15)",
+ degraded: "#e69138",
+ degradedSoft: "rgba(230,145,56,0.18)",
+ down: "#ff5b6b",
+ downSoft: "rgba(255,91,107,0.18)",
+ warn: "#f0a837",
+ warnSoft: "rgba(240,168,55,0.18)",
+ radius: "12px",
+ },
+};
+
+const modern: ThemeVariants = {
+ light: {
+ bg: "#fafbfc",
+ surface: "#ffffff",
+ border: "#eceef2",
+ text: "#0b1220",
+ textMuted: "#6b7280",
+ up: "#10a37f",
+ upStrong: "#0b8a6a",
+ upSoft: "#d7f3e9",
+ degraded: "#d97706",
+ degradedSoft: "#fef3c7",
+ down: "#dc2626",
+ downSoft: "#fee2e2",
+ warn: "#f59e0b",
+ warnSoft: "#fef3c7",
+ radius: "16px",
+ pulseStatusDot: true,
+ staggeredCardFadeIn: true,
+ },
+ dark: {
+ bg: "#0a0d12",
+ surface: "#10151c",
+ border: "#1b2430",
+ text: "#e8edf5",
+ textMuted: "#8a95a8",
+ up: "#10a37f",
+ upStrong: "#0b8a6a",
+ upSoft: "rgba(16,163,127,0.18)",
+ degraded: "#d97706",
+ degradedSoft: "rgba(217,119,6,0.22)",
+ down: "#dc2626",
+ downSoft: "rgba(220,38,38,0.22)",
+ warn: "#f59e0b",
+ warnSoft: "rgba(245,158,11,0.2)",
+ radius: "16px",
+ pulseStatusDot: true,
+ staggeredCardFadeIn: true,
+ },
+};
+
+const bold: ThemeVariants = {
+ light: {
+ bg: "#f5f6f9",
+ surface: "#ffffff",
+ border: "#e6e8ee",
+ text: "#0b1220",
+ textMuted: "#6b7280",
+ up: "#10a37f",
+ upStrong: "#0b8a6a",
+ upSoft: "rgba(16,163,127,0.12)",
+ degraded: "#f59e0b",
+ degradedSoft: "rgba(245,158,11,0.14)",
+ down: "#f43f5e",
+ downSoft: "rgba(244,63,94,0.12)",
+ warn: "#f59e0b",
+ warnSoft: "rgba(245,158,11,0.14)",
+ radius: "18px",
+ heroSize: "large",
+ conicLogo: true,
+ },
+ dark: {
+ bg: "#05070a",
+ surface: "#0c1117",
+ border: "#1a2330",
+ text: "#eef2f7",
+ textMuted: "#7a8699",
+ up: "#22d3a6",
+ upStrong: "#10a37f",
+ upSoft: "rgba(34,211,166,0.14)",
+ degraded: "#f59e0b",
+ degradedSoft: "rgba(245,158,11,0.14)",
+ down: "#f43f5e",
+ downSoft: "rgba(244,63,94,0.16)",
+ warn: "#f59e0b",
+ warnSoft: "rgba(245,158,11,0.14)",
+ radius: "18px",
+ heroSize: "large",
+ conicLogo: true,
+ },
+};
+
+const editorial: ThemeVariants = {
+ light: {
+ bg: "#fbfaf7",
+ surface: "#ffffff",
+ // Darkened from mockup's #e9e4d8 (~1.3:1 on #fbfaf7) to pass WCAG AA 3:1 for UI borders.
+ border: "#d4cdb8",
+ text: "#1a1a1a",
+ textMuted: "#6b675e",
+ up: "#2c6a4f",
+ upStrong: "#205239",
+ upSoft: "#dfeee4",
+ degraded: "#a46200",
+ degradedSoft: "#f7ecd4",
+ down: "#9a2a2a",
+ downSoft: "#f5dede",
+ warn: "#a46200",
+ warnSoft: "#f7ecd4",
+ radius: "6px",
+ headingFontFamily: 'Georgia, "Iowan Old Style", "Palatino Linotype", serif',
+ cardStyle: "hairline",
+ },
+ dark: {
+ bg: "#141310",
+ surface: "#1c1a15",
+ border: "#3a352b",
+ text: "#f0ece3",
+ textMuted: "#9e988a",
+ up: "#6fbf96",
+ upStrong: "#4fa37a",
+ upSoft: "rgba(44,106,79,0.22)",
+ degraded: "#d79a3b",
+ degradedSoft: "rgba(164,98,0,0.22)",
+ down: "#d87a7a",
+ downSoft: "rgba(154,42,42,0.22)",
+ warn: "#d79a3b",
+ warnSoft: "rgba(164,98,0,0.22)",
+ radius: "6px",
+ headingFontFamily: 'Georgia, "Iowan Old Style", "Palatino Linotype", serif',
+ cardStyle: "hairline",
+ },
+};
+
+export const themeTokens: Record = {
+ refined,
+ modern,
+ bold,
+ editorial,
+};
+
+export const toCssVars = (tokens: StatusPageThemeTokens): Record => ({
+ "--sp-bg": tokens.bg,
+ "--sp-surface": tokens.surface,
+ "--sp-border": tokens.border,
+ "--sp-text": tokens.text,
+ "--sp-text-muted": tokens.textMuted,
+ "--sp-up": tokens.up,
+ "--sp-up-strong": tokens.upStrong,
+ "--sp-up-soft": tokens.upSoft,
+ "--sp-degraded": tokens.degraded,
+ "--sp-degraded-soft": tokens.degradedSoft,
+ "--sp-down": tokens.down,
+ "--sp-down-soft": tokens.downSoft,
+ "--sp-warn": tokens.warn,
+ "--sp-warn-soft": tokens.warnSoft,
+ "--sp-radius": tokens.radius,
+ ...(tokens.chartBarRadius ? { "--sp-chart-bar-radius": tokens.chartBarRadius } : {}),
+});
diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts
index 6fe641210..6388ccfd9 100644
--- a/client/src/Types/StatusPage.ts
+++ b/client/src/Types/StatusPage.ts
@@ -21,6 +21,14 @@ export const getMonitorTypeLabel = (
return key ? t(key) : type;
};
+export const STATUS_PAGE_THEMES = ["refined", "modern", "bold", "editorial"] as const;
+export type StatusPageTheme = (typeof STATUS_PAGE_THEMES)[number];
+export const DEFAULT_STATUS_PAGE_THEME: StatusPageTheme = "refined";
+
+export const STATUS_PAGE_THEME_MODES = ["auto", "light", "dark"] as const;
+export type StatusPageThemeMode = (typeof STATUS_PAGE_THEME_MODES)[number];
+export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto";
+
export interface StatusPage {
id: string;
userId: string;
@@ -43,6 +51,8 @@ export interface StatusPage {
showAdminLoginLink: boolean;
showInfrastructure: boolean;
customCSS: string;
+ theme?: StatusPageTheme;
+ themeMode?: StatusPageThemeMode;
createdAt: string;
updatedAt: string;
}
diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts
index 43d7198f0..8b41b7780 100644
--- a/client/src/Validation/statusPage.ts
+++ b/client/src/Validation/statusPage.ts
@@ -1,4 +1,5 @@
import { z } from "zod";
+import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage";
export const statusPageSchema = z.object({
companyName: z
@@ -25,6 +26,8 @@ export const statusPageSchema = z.object({
showAdminLoginLink: z.boolean(),
showInfrastructure: z.boolean(),
customCSS: z.string().optional(),
+ theme: z.enum(STATUS_PAGE_THEMES).optional(),
+ themeMode: z.enum(STATUS_PAGE_THEME_MODES).optional(),
logo: z
.object({
data: z.string(),
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index eb9a34339..96aaa4045 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -1214,6 +1214,20 @@
"chartTypeHeatmap": "Heatmap",
"chartTypeHistogram": "Histogram",
"noData": "No data available",
+ "status": {
+ "up": "Operational",
+ "down": "Down",
+ "breached": "Threshold exceeded",
+ "maintenance": "Maintenance",
+ "paused": "Paused",
+ "initializing": "Initializing"
+ },
+ "chart": {
+ "avg": "avg {{value}}ms",
+ "max": "max {{value}}ms",
+ "downTooltip": "Down",
+ "heatmapAria": "Response time heatmap"
+ },
"infrastructure": {
"title": "Infrastructure",
"cpu": "CPU",
@@ -1230,6 +1244,8 @@
}
},
"statusBar": {
+ "monitoringSummary": "Monitoring {{count}} service · Updates every 10 seconds",
+ "monitoringSummary_other": "Monitoring {{count}} services · Updates every 10 seconds",
"allUp": "All systems operational",
"allBreached": "All systems have exceeded thresholds",
"allMaintenance": "All systems are under maintenance",
@@ -1261,6 +1277,13 @@
"addMonitor": "Add a monitor to get started"
}
},
+ "footer": {
+ "poweredBy": "Powered by"
+ },
+ "editorial": {
+ "reportTitle": "Service Report",
+ "allUp": "All systems are operating normally."
+ },
"form": {
"access": {
"title": "Access",
@@ -1318,6 +1341,38 @@
}
}
},
+ "theme": {
+ "title": "Page theme",
+ "description": "Choose the visual style for your public status page.",
+ "label": "Theme",
+ "previewLight": "Light",
+ "previewDark": "Dark",
+ "options": {
+ "refined": {
+ "name": "Refined",
+ "description": "Clean, professional, close to the classic Checkmate look."
+ },
+ "modern": {
+ "name": "Modern",
+ "description": "SaaS-grade with a pulsing status dot and softly fading cards."
+ },
+ "bold": {
+ "name": "Bold",
+ "description": "Large type and confident spacing. Great for brand-forward pages."
+ },
+ "editorial": {
+ "name": "Editorial",
+ "description": "Serif headings and hairline dividers. Distinctive and calm."
+ }
+ }
+ },
+ "themeMode": {
+ "label": "Theme mode",
+ "description": "Auto follows the visitor's device preference.",
+ "auto": "Auto (follow visitor's device)",
+ "light": "Light only",
+ "dark": "Dark only"
+ },
"features": {
"title": "Features",
"description": "Configure what information is displayed on your status page.",
From 163f5ec1ecaf731b856185a477710c6c5949963d Mon Sep 17 00:00:00 2001
From: gorkem-bwl
Date: Mon, 27 Apr 2026 18:21:31 -0400
Subject: [PATCH 02/19] refactor(status-page): convert refined theme to MUI sx
+ theme tokens
The reviewer's feedback on this PR is that the themes ship hardcoded class names against per-theme CSS files instead of following the project's MUI sx + theme.spacing()/palette pattern. This commit handles the refined theme as a proof of concept; the other three follow.
What changed
- New src/Pages/StatusPage/Status/themes/refined/styles.ts: a single function (tokens, isDark) -> sx recipes that mirror every rule in the deleted refined.css. All values are derived from the tokens already declared in tokens.ts so the colour/spacing source of truth doesn't change.
- RefinedStatusPage.tsx: drops the className= attributes and the import "./refined.css" in favour of sx={styles.x} drawn from the recipe map. Hardcoded class strings gone.
- refined.css: deleted (was 420 lines).
- Shared themed components (ThemedHeatmap, ThemedHistogram, ThemedInfrastructure): grew an opt-in sx-based API alongside the existing classPrefix one. The new API is what refined uses; bold / modern / editorial keep working unchanged on the legacy classPrefix path until they are migrated, after which the legacy code path is removed.
Verified
- tsc --noEmit clean
- prettier clean
- Pre-existing vite build error on this branch (flag-icons resolve, unrelated) is untouched
Next
- Convert the bold, modern and editorial themes the same way and remove the legacy classPrefix branch from the shared components.
---
.../themes/refined/RefinedStatusPage.tsx | 122 +++--
.../Status/themes/refined/refined.css | 420 ------------------
.../Status/themes/refined/styles.ts | 352 +++++++++++++++
.../Status/themes/shared/ThemedHeatmap.tsx | 31 +-
.../Status/themes/shared/ThemedHistogram.tsx | 59 ++-
.../themes/shared/ThemedInfrastructure.tsx | 62 ++-
6 files changed, 553 insertions(+), 493 deletions(-)
delete mode 100644 client/src/Pages/StatusPage/Status/themes/refined/refined.css
create mode 100644 client/src/Pages/StatusPage/Status/themes/refined/styles.ts
diff --git a/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx
index 5be6eab21..ef4f73596 100644
--- a/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx
@@ -14,7 +14,8 @@ import {
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
-import "./refined.css";
+import { useStatusPageTheme } from "../StatusPageThemeProvider";
+import { refinedStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
@@ -23,10 +24,10 @@ interface Props {
monitors: StatusPageMonitor[];
}
-const PREFIX = "rf";
-
export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
+ const { tokens, mode } = useStatusPageTheme();
+ const styles = refinedStyles(tokens, mode === "dark");
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const overall = resolveOverallStatus(monitors, t);
@@ -35,72 +36,85 @@ export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
: null;
return (
-
+
-
+
{logoSrc ? (
) : (
-
- {monoFirstChar(statusPage.companyName)}
-
+ {monoFirstChar(statusPage.companyName)}
)}
- {statusPage.companyName}
+
+ {statusPage.companyName}
+
-
-
-
- {overall.message}
-
+
+
+
+
+ {overall.message}
+
+
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
-
+
- {overall.icon}
+ {overall.icon}
{statusPage.showCharts && (
-
+
- setChartMode("heatmap")}
+ sx={(styles.chartSwitchButton as Function)(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
-
-
+ setChartMode("histogram")}
+ sx={(styles.chartSwitchButton as Function)(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
-
+
)}
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
@@ -112,48 +126,68 @@ export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
-
-
- {monitor.name}
-
-
+
+ {monitor.name}
+
+
{getMonitorTypeLabel(monitor.type, t)}
-
+
{monitor.url && (
-
{monitor.url}
-
+
)}
-
+
{t(statusBadgeKey[monitor.status])}
-
+
{showInfra && (
any,
+ gaugeSubSx: styles.gaugeSub,
+ }}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
any}
/>
) : (
any}
+ statsSx={styles.chartStats}
/>
))}
@@ -163,7 +197,7 @@ export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
{t("pages.statusPages.footer.poweredBy")}{" "}
button {
- border: 0;
- background: transparent;
- font:
- inherit,
- -apple-system,
- sans-serif;
- font-size: 11px;
- padding: 5px 14px;
- cursor: pointer;
- color: var(--sp-text-muted);
- border-radius: 5px;
- transition:
- background 0.15s ease,
- color 0.15s ease;
- font-weight: 500;
-}
-[data-status-theme="refined"] .rf-chart-switch > button:hover {
- color: var(--sp-text);
-}
-[data-status-theme="refined"] .rf-chart-switch > button.rf-active {
- color: var(--sp-up);
- background: var(--sp-up-soft);
- font-weight: 600;
-}
-
-/* --- monitor card list --- */
-[data-status-theme="refined"] .rf-monitor-list {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-[data-status-theme="refined"] .rf-card {
- background: var(--sp-surface);
- border: 1px solid var(--sp-border);
- border-radius: var(--sp-radius);
- box-shadow: var(
- --sp-shadow,
- 0 1px 2px rgba(10, 16, 32, 0.06),
- 0 6px 18px rgba(10, 16, 32, 0.08)
- );
- overflow: hidden;
- position: relative;
- transition:
- transform 0.15s,
- box-shadow 0.15s;
-}
-[data-status-theme="refined"] .rf-card:hover {
- transform: translateY(-1px);
- box-shadow:
- 0 2px 4px rgba(16, 24, 40, 0.06),
- 0 10px 24px rgba(16, 24, 40, 0.06);
-}
-
-[data-status-theme="refined"] .rf-card-row {
- display: grid;
- grid-template-columns: 1fr auto;
- align-items: center;
- gap: 16px;
- padding: 16px 20px;
-}
-[data-status-theme="refined"] .rf-card-left {
- min-width: 0;
-}
-[data-status-theme="refined"] .rf-monitor-name {
- font-weight: 600;
- font-size: 14px;
- letter-spacing: -0.005em;
- color: var(--sp-text);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-[data-status-theme="refined"] .rf-monitor-meta {
- display: flex;
- gap: 10px;
- align-items: center;
- margin-top: 4px;
- flex-wrap: wrap;
-}
-[data-status-theme="refined"] .rf-pill {
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--sp-text-muted);
- border: 1px solid var(--sp-border);
- padding: 2px 8px;
- border-radius: 999px;
- font-weight: 600;
-}
-[data-status-theme="refined"] .rf-pill-hardware {
- color: #1d4ed8;
- border-color: rgba(29, 78, 216, 0.3);
- background: rgba(29, 78, 216, 0.06);
-}
-[data-status-theme="refined"][data-status-mode="dark"] .rf-pill-hardware {
- color: #60a5fa;
- border-color: rgba(96, 165, 250, 0.3);
- background: rgba(96, 165, 250, 0.1);
-}
-[data-status-theme="refined"] .rf-monitor-url {
- font-size: 12px;
- color: var(--sp-text-muted);
- font-family: ui-monospace, Menlo, monospace;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 280px;
-}
-
-[data-status-theme="refined"] .rf-badge {
- font-size: 11px;
- font-weight: 600;
- padding: 4px 10px;
- border-radius: 999px;
- white-space: nowrap;
-}
-[data-status-theme="refined"] .rf-badge-up {
- background: var(--sp-up-soft);
- color: var(--sp-up);
-}
-[data-status-theme="refined"] .rf-badge-warn {
- background: var(--sp-warn-soft);
- color: var(--sp-warn);
-}
-[data-status-theme="refined"] .rf-badge-down {
- background: var(--sp-down-soft);
- color: var(--sp-down);
-}
-
-/* --- heatmap --- */
-[data-status-theme="refined"] .rf-heatmap {
- padding: 0 20px 16px;
- display: grid;
- grid-template-columns: repeat(25, 1fr);
- gap: 3px;
- height: 42px;
-}
-[data-status-theme="refined"] .rf-heatmap-cell {
- border-radius: 2px;
- background: var(--sp-up);
- transition: transform 0.15s;
-}
-[data-status-theme="refined"] .rf-heatmap-cell:hover {
- transform: scaleY(1.15);
-}
-[data-status-theme="refined"] .rf-heatmap-cell.rf-fast {
- background: var(--sp-up);
-}
-[data-status-theme="refined"] .rf-heatmap-cell.rf-med {
- background: color-mix(in srgb, var(--sp-up) 60%, #ffffff 40%);
-}
-[data-status-theme="refined"] .rf-heatmap-cell.rf-slow {
- background: var(--sp-warn);
-}
-[data-status-theme="refined"] .rf-heatmap-cell.rf-down {
- background: var(--sp-down);
-}
-[data-status-theme="refined"] .rf-heatmap-cell.rf-empty {
- background: var(--sp-border);
- opacity: 0.4;
-}
-
-/* --- histogram --- */
-[data-status-theme="refined"] .rf-histogram {
- padding: 0 20px;
- display: grid;
- grid-template-columns: repeat(25, 1fr);
- gap: 3px;
- align-items: flex-end;
- height: 42px;
-}
-[data-status-theme="refined"] .rf-bar {
- background: var(--sp-up);
- border-radius: 2px;
- min-height: 3px;
-}
-[data-status-theme="refined"] .rf-bar.rf-down {
- background: var(--sp-down);
-}
-[data-status-theme="refined"] .rf-bar.rf-empty {
- background: var(--sp-border);
- opacity: 0.4;
-}
-[data-status-theme="refined"] .rf-chart-stats {
- padding: 0 20px 16px;
- font-size: 11px;
- color: var(--sp-text-muted);
- font-variant-numeric: tabular-nums;
-}
-
-/* --- infrastructure gauges --- */
-[data-status-theme="refined"] .rf-infra {
- padding: 14px 20px 18px;
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 12px;
-}
-@media (max-width: 720px) {
- [data-status-theme="refined"] .rf-infra {
- grid-template-columns: 1fr;
- }
-}
-[data-status-theme="refined"] .rf-infra-empty {
- padding: 14px 20px 18px;
- color: var(--sp-text-muted);
- font-size: 13px;
-}
-[data-status-theme="refined"] .rf-gauge {
- border: 1px solid var(--sp-border);
- border-radius: 10px;
- padding: 12px 14px;
- background: var(--sp-bg);
-}
-[data-status-theme="refined"] .rf-gauge-label {
- font-size: 11px;
- color: var(--sp-text-muted);
- text-transform: uppercase;
- letter-spacing: 0.08em;
- font-weight: 600;
-}
-[data-status-theme="refined"] .rf-gauge-value {
- font-size: 20px;
- font-weight: 600;
- letter-spacing: -0.01em;
- margin-top: 2px;
- font-variant-numeric: tabular-nums;
- color: var(--sp-text);
-}
-[data-status-theme="refined"] .rf-gauge-bar {
- height: 4px;
- background: var(--sp-border);
- border-radius: 2px;
- overflow: hidden;
- margin-top: 8px;
-}
-[data-status-theme="refined"] .rf-gauge-fill {
- display: block;
- height: 100%;
- background: var(--sp-up);
- border-radius: 2px;
- transition: width 0.6s;
-}
-[data-status-theme="refined"] .rf-gauge-fill.rf-warm {
- background: var(--sp-warn);
-}
-[data-status-theme="refined"] .rf-gauge-fill.rf-hot {
- background: var(--sp-down);
-}
-[data-status-theme="refined"] .rf-gauge-sub {
- font-size: 11px;
- color: var(--sp-text-muted);
- margin-top: 6px;
- font-variant-numeric: tabular-nums;
-}
-
-/* --- footer --- */
-[data-status-theme="refined"] .rf-footer {
- text-align: center;
- color: var(--sp-text-muted);
- font-size: 12px;
- margin-top: 40px;
-}
-[data-status-theme="refined"] .rf-footer a {
- color: var(--sp-up);
- text-decoration: underline;
- text-underline-offset: 3px;
- font-weight: 600;
-}
-[data-status-theme="refined"] .rf-footer a:hover {
- color: var(--sp-up-strong, var(--sp-up));
-}
diff --git a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts
new file mode 100644
index 000000000..39bc9bf56
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts
@@ -0,0 +1,352 @@
+import type { StatusPageThemeTokens } from "../tokens";
+
+export type RefinedTone = "up" | "warn" | "down";
+export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty";
+export type RefinedBarKind = "up" | "down" | "empty";
+export type RefinedGaugeFill = "ok" | "warm" | "hot";
+
+const sansFontStack =
+ '-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
+
+const cardShadow = "0 1px 2px rgba(10, 16, 32, 0.06), 0 6px 18px rgba(10, 16, 32, 0.08)";
+const cardShadowHover =
+ "0 2px 4px rgba(16, 24, 40, 0.06), 0 10px 24px rgba(16, 24, 40, 0.06)";
+
+const toneColor = (tone: RefinedTone, t: StatusPageThemeTokens) =>
+ tone === "down" ? t.down : tone === "warn" ? t.warn : t.up;
+
+const toneSoft = (tone: RefinedTone, t: StatusPageThemeTokens) =>
+ tone === "down" ? t.downSoft : tone === "warn" ? t.warnSoft : t.upSoft;
+
+export const refinedStyles = (tokens: StatusPageThemeTokens, isDark: boolean) => {
+ const heatCellBg: Record = {
+ fast: tokens.up,
+ med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
+ slow: tokens.warn,
+ down: tokens.down,
+ empty: tokens.border,
+ };
+
+ const barBg: Record = {
+ up: tokens.up,
+ down: tokens.down,
+ empty: tokens.border,
+ };
+
+ const gaugeFillBg: Record = {
+ ok: tokens.up,
+ warm: tokens.warn,
+ hot: tokens.down,
+ };
+
+ const sx: Record = {
+ page: {
+ flex: "1 0 auto",
+ maxWidth: 960,
+ width: "100%",
+ mx: "auto",
+ p: "48px 20px 80px",
+ fontFamily: sansFontStack,
+ fontSize: 14,
+ lineHeight: 1.5,
+ color: tokens.text,
+ WebkitFontSmoothing: "antialiased",
+ },
+
+ top: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ mb: "28px",
+ },
+ brand: {
+ display: "flex",
+ alignItems: "center",
+ gap: "10px",
+ fontWeight: 600,
+ letterSpacing: "-0.01em",
+ color: tokens.text,
+ },
+ logoMono: {
+ width: 28,
+ height: 28,
+ borderRadius: "8px",
+ background: tokens.up,
+ display: "grid",
+ placeItems: "center",
+ color: "#fff",
+ fontWeight: 700,
+ fontSize: 13,
+ },
+ logoImg: {
+ maxHeight: 32,
+ maxWidth: 120,
+ objectFit: "contain",
+ },
+ company: { fontSize: 14 },
+
+ hero: {
+ background: tokens.surface,
+ border: `1px solid ${tokens.border}`,
+ borderRadius: tokens.radius,
+ padding: "22px 24px",
+ display: "flex",
+ alignItems: "center",
+ gap: "16px",
+ boxShadow: cardShadow,
+ mb: "20px",
+ },
+ statusDot: (tone: RefinedTone) => ({
+ width: 10,
+ height: 10,
+ borderRadius: "50%",
+ background: toneColor(tone, tokens),
+ boxShadow: `0 0 0 4px ${toneSoft(tone, tokens)}`,
+ flexShrink: 0,
+ }),
+ statusCopy: { flex: 1, minWidth: 0 },
+ heroTitle: {
+ m: 0,
+ mb: "2px",
+ fontSize: 17,
+ fontWeight: 600,
+ letterSpacing: "-0.01em",
+ color: tokens.text,
+ },
+ heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
+ heroIcon: (tone: RefinedTone) => ({
+ color: toneColor(tone, tokens),
+ display: "flex",
+ alignItems: "center",
+ }),
+
+ chartSwitchWrap: {
+ display: "flex",
+ justifyContent: "flex-end",
+ mb: "12px",
+ },
+ chartSwitch: {
+ display: "inline-flex",
+ border: `1px solid ${tokens.border}`,
+ borderRadius: "8px",
+ background: tokens.surface,
+ p: "3px",
+ gap: "2px",
+ },
+ chartSwitchButton: (active: boolean) => ({
+ border: 0,
+ background: active ? tokens.upSoft : "transparent",
+ fontFamily: "inherit",
+ fontSize: 11,
+ padding: "5px 14px",
+ cursor: "pointer",
+ color: active ? tokens.up : tokens.textMuted,
+ borderRadius: "5px",
+ transition: "background 0.15s ease, color 0.15s ease",
+ fontWeight: active ? 600 : 500,
+ "&:hover": { color: active ? tokens.up : tokens.text },
+ }),
+
+ monitorList: {
+ listStyle: "none",
+ m: 0,
+ p: 0,
+ display: "flex",
+ flexDirection: "column",
+ gap: "12px",
+ },
+ card: {
+ background: tokens.surface,
+ border: `1px solid ${tokens.border}`,
+ borderRadius: tokens.radius,
+ boxShadow: cardShadow,
+ overflow: "hidden",
+ position: "relative",
+ transition: "transform 0.15s, box-shadow 0.15s",
+ "&:hover": { transform: "translateY(-1px)", boxShadow: cardShadowHover },
+ },
+ cardRow: {
+ display: "grid",
+ gridTemplateColumns: "1fr auto",
+ alignItems: "center",
+ gap: "16px",
+ p: "16px 20px",
+ },
+ cardLeft: { minWidth: 0 },
+ monitorName: {
+ fontWeight: 600,
+ fontSize: 14,
+ letterSpacing: "-0.005em",
+ color: tokens.text,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ },
+ monitorMeta: {
+ display: "flex",
+ gap: "10px",
+ alignItems: "center",
+ mt: "4px",
+ flexWrap: "wrap",
+ },
+ pill: {
+ fontSize: 10,
+ textTransform: "uppercase",
+ letterSpacing: "0.08em",
+ color: tokens.textMuted,
+ border: `1px solid ${tokens.border}`,
+ padding: "2px 8px",
+ borderRadius: "999px",
+ fontWeight: 600,
+ },
+ pillHardware: isDark
+ ? {
+ fontSize: 10,
+ textTransform: "uppercase",
+ letterSpacing: "0.08em",
+ padding: "2px 8px",
+ borderRadius: "999px",
+ fontWeight: 600,
+ color: "#60a5fa",
+ border: "1px solid rgba(96, 165, 250, 0.3)",
+ background: "rgba(96, 165, 250, 0.1)",
+ }
+ : {
+ fontSize: 10,
+ textTransform: "uppercase",
+ letterSpacing: "0.08em",
+ padding: "2px 8px",
+ borderRadius: "999px",
+ fontWeight: 600,
+ color: "#1d4ed8",
+ border: "1px solid rgba(29, 78, 216, 0.3)",
+ background: "rgba(29, 78, 216, 0.06)",
+ },
+ monitorUrl: {
+ fontSize: 12,
+ color: tokens.textMuted,
+ fontFamily: "ui-monospace, Menlo, monospace",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ maxWidth: 280,
+ },
+
+ badge: (tone: RefinedTone) => ({
+ fontSize: 11,
+ fontWeight: 600,
+ padding: "4px 10px",
+ borderRadius: "999px",
+ whiteSpace: "nowrap",
+ background: toneSoft(tone, tokens),
+ color: toneColor(tone, tokens),
+ }),
+
+ heatmap: {
+ padding: "0 20px 16px",
+ display: "grid",
+ gridTemplateColumns: "repeat(25, 1fr)",
+ gap: "3px",
+ height: 42,
+ },
+ heatmapCell: (kind: RefinedHeatCell) => ({
+ borderRadius: "2px",
+ background: heatCellBg[kind],
+ opacity: kind === "empty" ? 0.4 : 1,
+ transition: "transform 0.15s",
+ "&:hover": { transform: "scaleY(1.15)" },
+ }),
+
+ histogram: {
+ padding: "0 20px",
+ display: "grid",
+ gridTemplateColumns: "repeat(25, 1fr)",
+ gap: "3px",
+ alignItems: "flex-end",
+ height: 42,
+ },
+ bar: (kind: RefinedBarKind, heightPct: number) => ({
+ background: barBg[kind],
+ borderRadius: "2px",
+ minHeight: 3,
+ opacity: kind === "empty" ? 0.4 : 1,
+ height: `${heightPct}%`,
+ }),
+ chartStats: {
+ padding: "0 20px 16px",
+ fontSize: 11,
+ color: tokens.textMuted,
+ fontVariantNumeric: "tabular-nums",
+ },
+
+ infra: {
+ padding: "14px 20px 18px",
+ display: "grid",
+ gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
+ gap: "12px",
+ },
+ infraEmpty: {
+ padding: "14px 20px 18px",
+ color: tokens.textMuted,
+ fontSize: 13,
+ },
+ gauge: {
+ border: `1px solid ${tokens.border}`,
+ borderRadius: "10px",
+ p: "12px 14px",
+ background: tokens.bg,
+ },
+ gaugeLabel: {
+ fontSize: 11,
+ color: tokens.textMuted,
+ textTransform: "uppercase",
+ letterSpacing: "0.08em",
+ fontWeight: 600,
+ },
+ gaugeValue: {
+ fontSize: 20,
+ fontWeight: 600,
+ letterSpacing: "-0.01em",
+ mt: "2px",
+ fontVariantNumeric: "tabular-nums",
+ color: tokens.text,
+ },
+ gaugeBar: {
+ height: 4,
+ background: tokens.border,
+ borderRadius: "2px",
+ overflow: "hidden",
+ mt: "8px",
+ },
+ gaugeFill: (level: RefinedGaugeFill, widthPct: number) => ({
+ display: "block",
+ height: "100%",
+ background: gaugeFillBg[level],
+ borderRadius: "2px",
+ transition: "width 0.6s",
+ width: `${Math.max(0, Math.min(100, widthPct))}%`,
+ }),
+ gaugeSub: {
+ fontSize: 11,
+ color: tokens.textMuted,
+ mt: "6px",
+ fontVariantNumeric: "tabular-nums",
+ },
+
+ footer: {
+ textAlign: "center",
+ color: tokens.textMuted,
+ fontSize: 12,
+ mt: "40px",
+ "& a": {
+ color: tokens.up,
+ textDecoration: "underline",
+ textUnderlineOffset: "3px",
+ fontWeight: 600,
+ "&:hover": { color: tokens.upStrong || tokens.up },
+ },
+ },
+ };
+
+ return sx;
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
index 735f02fa8..2a442405c 100644
--- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
@@ -2,18 +2,25 @@ import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
+import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { CheckSnapshot } from "@/Types/Check";
import { formatCheckTimestamp } from "./timeFormat";
const CELLS = 25;
+export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty";
+
interface Props {
checks: CheckSnapshot[];
- classPrefix: string;
+ /** sx-based API — preferred. Themes pass their own container + cell recipes. */
+ containerSx?: SxProps;
+ cellSx?: (kind: HeatCellKind) => SxProps;
+ /** Legacy className-based API — kept for unmigrated themes. Will be removed once all themes move to sx. */
+ classPrefix?: string;
}
-const classify = (check: CheckSnapshot): "fast" | "med" | "slow" | "down" => {
+const classify = (check: CheckSnapshot): Exclude => {
if (!check.status) return "down";
const rt = check.responseTime ?? 0;
if (rt > 500) return "slow";
@@ -21,8 +28,9 @@ const classify = (check: CheckSnapshot): "fast" | "med" | "slow" | "down" => {
return "fast";
};
-export const ThemedHeatmap = ({ checks, classPrefix }: Props) => {
+export const ThemedHeatmap = ({ checks, containerSx, cellSx, classPrefix }: Props) => {
const { t } = useTranslation();
+ const useSxApi = Boolean(cellSx);
const source = checks.slice(0, CELLS).reverse();
const padded: (CheckSnapshot | null)[] = [
@@ -30,9 +38,18 @@ export const ThemedHeatmap = ({ checks, classPrefix }: Props) => {
...source,
];
+ const containerProps = useSxApi
+ ? { sx: containerSx }
+ : { className: `${classPrefix}-heatmap` };
+
+ const cellProps = (kind: HeatCellKind) =>
+ useSxApi
+ ? { sx: cellSx!(kind) }
+ : { className: `${classPrefix}-heatmap-cell ${classPrefix}-${kind}` };
+
return (
@@ -41,11 +58,11 @@ export const ThemedHeatmap = ({ checks, classPrefix }: Props) => {
return (
);
}
- const cls = classify(check);
+ const kind = classify(check);
const tooltipContent = (
{
placement="top"
>
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
index 83a63d01c..65027731b 100644
--- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
@@ -3,6 +3,7 @@ import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
+import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { CheckSnapshot } from "@/Types/Check";
import { formatCheckTimestamp } from "./timeFormat";
@@ -10,16 +11,32 @@ import { formatCheckTimestamp } from "./timeFormat";
const CELLS = 25;
const MIN_HEIGHT_PCT = 6;
+export type BarKind = "up" | "down" | "empty";
+
interface Props {
checks: CheckSnapshot[];
- classPrefix: string;
+ /** sx-based API — preferred. */
+ containerSx?: SxProps;
+ barSx?: (kind: BarKind, heightPct: number) => SxProps;
+ statsSx?: SxProps;
+ /** Legacy className-based API. Will be removed once all themes move to sx. */
+ classPrefix?: string;
statsGap?: number;
}
-const tone = (check: CheckSnapshot): "up" | "down" => (check.status ? "up" : "down");
+const tone = (check: CheckSnapshot): Exclude =>
+ check.status ? "up" : "down";
-export const ThemedHistogram = ({ checks, classPrefix, statsGap = 1 }: Props) => {
+export const ThemedHistogram = ({
+ checks,
+ containerSx,
+ barSx,
+ statsSx,
+ classPrefix,
+ statsGap = 1,
+}: Props) => {
const { t } = useTranslation();
+ const useSxApi = Boolean(barSx);
const { padded, max, avg, peak } = useMemo(() => {
const source = checks.slice(0, CELLS).reverse();
@@ -35,12 +52,25 @@ export const ThemedHistogram = ({ checks, classPrefix, statsGap = 1 }: Props) =>
return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 };
}, [checks]);
+ const containerProps = useSxApi
+ ? { sx: containerSx }
+ : { className: `${classPrefix}-histogram` };
+
+ const statsProps = useSxApi
+ ? { sx: statsSx }
+ : { className: `${classPrefix}-chart-stats` };
+
return (
-
+
{padded.map((check, i) => {
if (!check) {
- return (
+ return useSxApi ? (
+
+ ) : (
arrow
placement="top"
>
-
+ {useSxApi ? (
+
+ ) : (
+
+ )}
);
})}
@@ -89,7 +126,7 @@ export const ThemedHistogram = ({ checks, classPrefix, statsGap = 1 }: Props) =>
{t("pages.statusPages.monitorsList.chart.avg", { value: avg })}
{t("pages.statusPages.monitorsList.chart.max", { value: peak })}
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx
index f465ece91..d4334e959 100644
--- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx
@@ -1,11 +1,28 @@
import Box from "@mui/material/Box";
import prettyBytes from "pretty-bytes";
+import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
import type { Monitor } from "@/Types/Monitor";
+export type GaugeFillLevel = "ok" | "warm" | "hot";
+
+interface InfraSx {
+ containerSx: SxProps;
+ emptySx: SxProps;
+ gaugeSx: SxProps;
+ gaugeLabelSx: SxProps;
+ gaugeValueSx: SxProps;
+ gaugeBarSx: SxProps;
+ gaugeFillSx: (level: GaugeFillLevel, widthPct: number) => SxProps;
+ gaugeSubSx: SxProps;
+}
+
interface Props {
monitor: Monitor & { checks?: Monitor["recentChecks"] };
- classPrefix: string;
+ /** sx-based API — preferred. */
+ sxApi?: InfraSx;
+ /** Legacy className-based API. Will be removed once all themes move to sx. */
+ classPrefix?: string;
}
interface Gauge {
@@ -17,16 +34,24 @@ interface Gauge {
const PCT = 100;
-export const ThemedInfrastructure = ({ monitor, classPrefix }: Props) => {
+const heatLevel = (value: number): GaugeFillLevel =>
+ value > 85 ? "hot" : value > 70 ? "warm" : "ok";
+
+export const ThemedInfrastructure = ({ monitor, sxApi, classPrefix }: Props) => {
const { t } = useTranslation();
+ const useSxApi = Boolean(sxApi);
const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
- if (!latest) {
- return (
-
- {t("pages.statusPages.monitorsList.noData")}
-
+
+ const renderEmpty = () => {
+ const message = t("pages.statusPages.monitorsList.noData");
+ return useSxApi ? (
+ {message}
+ ) : (
+ {message}
);
- }
+ };
+
+ if (!latest) return renderEmpty();
const gauges: Gauge[] = [];
@@ -67,10 +92,25 @@ export const ThemedInfrastructure = ({ monitor, classPrefix }: Props) => {
});
}
- if (gauges.length === 0) {
+ if (gauges.length === 0) return renderEmpty();
+
+ if (useSxApi) {
+ const s = sxApi!;
return (
-
- {t("pages.statusPages.monitorsList.noData")}
+
+ {gauges.map((g) => (
+
+ {g.label}
+ {g.value.toFixed(0)}%
+
+
+
+ {g.sub}
+
+ ))}
);
}
From b95d8886b910b10c2cd665ce6c458000b2814034 Mon Sep 17 00:00:00 2001
From: gorkem-bwl
Date: Mon, 27 Apr 2026 18:30:04 -0400
Subject: [PATCH 03/19] refactor(status-page): tighten the refined-theme
conversion
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Apply review feedback:
- Type the styles factory's return as a named RefinedStyles interface so the consumer no longer needs `as Function` / `as any` casts on factory entries
- Lift toneColor / toneSoft to shared/overallStatus alongside the existing OverallTone type — the next three themes will need the same mapping
- Reuse heatLevel() in the legacy classPrefix branch of ThemedInfrastructure so both API paths share one threshold table
- Memoize the styles map in RefinedStatusPage so MUI's sx processor sees referential equality across renders that don't actually change tokens
- Make the dual-API gate in ThemedHeatmap / ThemedHistogram require ALL relevant sx props together so a partial migration can't silently fall back to the legacy class path
---
.../themes/refined/RefinedStatusPage.tsx | 23 ++--
.../Status/themes/refined/styles.ts | 122 ++++++++++--------
.../Status/themes/shared/ThemedHeatmap.tsx | 2 +-
.../Status/themes/shared/ThemedHistogram.tsx | 2 +-
.../themes/shared/ThemedInfrastructure.tsx | 4 +-
.../Status/themes/shared/overallStatus.tsx | 7 +
6 files changed, 92 insertions(+), 68 deletions(-)
diff --git a/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx
index ef4f73596..332e263bc 100644
--- a/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
@@ -27,7 +27,7 @@ interface Props {
export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
const { tokens, mode } = useStatusPageTheme();
- const styles = refinedStyles(tokens, mode === "dark");
+ const styles = useMemo(() => refinedStyles(tokens, mode === "dark"), [tokens, mode]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const overall = resolveOverallStatus(monitors, t);
@@ -62,7 +62,7 @@ export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
-
+
{
})}
- {overall.icon}
+ {overall.icon}
{statusPage.showCharts && (
@@ -94,7 +94,7 @@ export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
- sx={(styles.chartSwitchButton as Function)(chartMode === "heatmap")}
+ sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
@@ -104,7 +104,7 @@ export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
- sx={(styles.chartSwitchButton as Function)(chartMode === "histogram")}
+ sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
@@ -151,7 +151,7 @@ export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
{t(statusBadgeKey[monitor.status])}
@@ -167,10 +167,7 @@ export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
gaugeLabelSx: styles.gaugeLabel,
gaugeValueSx: styles.gaugeValue,
gaugeBarSx: styles.gaugeBar,
- gaugeFillSx: styles.gaugeFill as (
- level: "ok" | "warm" | "hot",
- width: number
- ) => any,
+ gaugeFillSx: styles.gaugeFill,
gaugeSubSx: styles.gaugeSub,
}}
/>
@@ -180,13 +177,13 @@ export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
any}
+ cellSx={styles.heatmapCell}
/>
) : (
any}
+ barSx={styles.bar}
statsSx={styles.chartStats}
/>
))}
diff --git a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts
index 39bc9bf56..0789efd87 100644
--- a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts
+++ b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts
@@ -1,10 +1,53 @@
+import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
+import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
-export type RefinedTone = "up" | "warn" | "down";
export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type RefinedBarKind = "up" | "down" | "empty";
export type RefinedGaugeFill = "ok" | "warm" | "hot";
+export interface RefinedStyles {
+ page: SxProps;
+ top: SxProps;
+ brand: SxProps;
+ logoMono: SxProps;
+ logoImg: SxProps;
+ company: SxProps;
+ hero: SxProps;
+ statusDot: (tone: OverallTone) => SxProps;
+ statusCopy: SxProps;
+ heroTitle: SxProps;
+ heroSub: SxProps;
+ heroIcon: (tone: OverallTone) => SxProps;
+ chartSwitchWrap: SxProps;
+ chartSwitch: SxProps;
+ chartSwitchButton: (active: boolean) => SxProps;
+ monitorList: SxProps;
+ card: SxProps;
+ cardRow: SxProps;
+ cardLeft: SxProps;
+ monitorName: SxProps;
+ monitorMeta: SxProps;
+ pill: SxProps;
+ pillHardware: SxProps;
+ monitorUrl: SxProps;
+ badge: (tone: OverallTone) => SxProps;
+ heatmap: SxProps;
+ heatmapCell: (kind: RefinedHeatCell) => SxProps;
+ histogram: SxProps;
+ bar: (kind: RefinedBarKind, heightPct: number) => SxProps;
+ chartStats: SxProps;
+ infra: SxProps;
+ infraEmpty: SxProps;
+ gauge: SxProps;
+ gaugeLabel: SxProps;
+ gaugeValue: SxProps;
+ gaugeBar: SxProps;
+ gaugeFill: (level: RefinedGaugeFill, widthPct: number) => SxProps;
+ gaugeSub: SxProps;
+ footer: SxProps;
+}
+
const sansFontStack =
'-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
@@ -12,13 +55,10 @@ const cardShadow = "0 1px 2px rgba(10, 16, 32, 0.06), 0 6px 18px rgba(10, 16, 32
const cardShadowHover =
"0 2px 4px rgba(16, 24, 40, 0.06), 0 10px 24px rgba(16, 24, 40, 0.06)";
-const toneColor = (tone: RefinedTone, t: StatusPageThemeTokens) =>
- tone === "down" ? t.down : tone === "warn" ? t.warn : t.up;
-
-const toneSoft = (tone: RefinedTone, t: StatusPageThemeTokens) =>
- tone === "down" ? t.downSoft : tone === "warn" ? t.warnSoft : t.upSoft;
-
-export const refinedStyles = (tokens: StatusPageThemeTokens, isDark: boolean) => {
+export const refinedStyles = (
+ tokens: StatusPageThemeTokens,
+ isDark: boolean
+): RefinedStyles => {
const heatCellBg: Record = {
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
@@ -39,7 +79,7 @@ export const refinedStyles = (tokens: StatusPageThemeTokens, isDark: boolean) =>
hot: tokens.down,
};
- const sx: Record = {
+ return {
page: {
flex: "1 0 auto",
maxWidth: 960,
@@ -78,11 +118,7 @@ export const refinedStyles = (tokens: StatusPageThemeTokens, isDark: boolean) =>
fontWeight: 700,
fontSize: 13,
},
- logoImg: {
- maxHeight: 32,
- maxWidth: 120,
- objectFit: "contain",
- },
+ logoImg: { maxHeight: 32, maxWidth: 120, objectFit: "contain" },
company: { fontSize: 14 },
hero: {
@@ -96,7 +132,7 @@ export const refinedStyles = (tokens: StatusPageThemeTokens, isDark: boolean) =>
boxShadow: cardShadow,
mb: "20px",
},
- statusDot: (tone: RefinedTone) => ({
+ statusDot: (tone) => ({
width: 10,
height: 10,
borderRadius: "50%",
@@ -114,17 +150,13 @@ export const refinedStyles = (tokens: StatusPageThemeTokens, isDark: boolean) =>
color: tokens.text,
},
heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
- heroIcon: (tone: RefinedTone) => ({
+ heroIcon: (tone) => ({
color: toneColor(tone, tokens),
display: "flex",
alignItems: "center",
}),
- chartSwitchWrap: {
- display: "flex",
- justifyContent: "flex-end",
- mb: "12px",
- },
+ chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "12px" },
chartSwitch: {
display: "inline-flex",
border: `1px solid ${tokens.border}`,
@@ -133,7 +165,7 @@ export const refinedStyles = (tokens: StatusPageThemeTokens, isDark: boolean) =>
p: "3px",
gap: "2px",
},
- chartSwitchButton: (active: boolean) => ({
+ chartSwitchButton: (active) => ({
border: 0,
background: active ? tokens.upSoft : "transparent",
fontFamily: "inherit",
@@ -199,29 +231,19 @@ export const refinedStyles = (tokens: StatusPageThemeTokens, isDark: boolean) =>
borderRadius: "999px",
fontWeight: 600,
},
- pillHardware: isDark
- ? {
- fontSize: 10,
- textTransform: "uppercase",
- letterSpacing: "0.08em",
- padding: "2px 8px",
- borderRadius: "999px",
- fontWeight: 600,
- color: "#60a5fa",
- border: "1px solid rgba(96, 165, 250, 0.3)",
- background: "rgba(96, 165, 250, 0.1)",
- }
- : {
- fontSize: 10,
- textTransform: "uppercase",
- letterSpacing: "0.08em",
- padding: "2px 8px",
- borderRadius: "999px",
- fontWeight: 600,
- color: "#1d4ed8",
- border: "1px solid rgba(29, 78, 216, 0.3)",
- background: "rgba(29, 78, 216, 0.06)",
- },
+ pillHardware: {
+ fontSize: 10,
+ textTransform: "uppercase",
+ letterSpacing: "0.08em",
+ padding: "2px 8px",
+ borderRadius: "999px",
+ fontWeight: 600,
+ color: isDark ? "#60a5fa" : "#1d4ed8",
+ border: `1px solid ${
+ isDark ? "rgba(96, 165, 250, 0.3)" : "rgba(29, 78, 216, 0.3)"
+ }`,
+ background: isDark ? "rgba(96, 165, 250, 0.1)" : "rgba(29, 78, 216, 0.06)",
+ },
monitorUrl: {
fontSize: 12,
color: tokens.textMuted,
@@ -232,7 +254,7 @@ export const refinedStyles = (tokens: StatusPageThemeTokens, isDark: boolean) =>
maxWidth: 280,
},
- badge: (tone: RefinedTone) => ({
+ badge: (tone) => ({
fontSize: 11,
fontWeight: 600,
padding: "4px 10px",
@@ -249,7 +271,7 @@ export const refinedStyles = (tokens: StatusPageThemeTokens, isDark: boolean) =>
gap: "3px",
height: 42,
},
- heatmapCell: (kind: RefinedHeatCell) => ({
+ heatmapCell: (kind) => ({
borderRadius: "2px",
background: heatCellBg[kind],
opacity: kind === "empty" ? 0.4 : 1,
@@ -265,7 +287,7 @@ export const refinedStyles = (tokens: StatusPageThemeTokens, isDark: boolean) =>
alignItems: "flex-end",
height: 42,
},
- bar: (kind: RefinedBarKind, heightPct: number) => ({
+ bar: (kind, heightPct) => ({
background: barBg[kind],
borderRadius: "2px",
minHeight: 3,
@@ -318,7 +340,7 @@ export const refinedStyles = (tokens: StatusPageThemeTokens, isDark: boolean) =>
overflow: "hidden",
mt: "8px",
},
- gaugeFill: (level: RefinedGaugeFill, widthPct: number) => ({
+ gaugeFill: (level, widthPct) => ({
display: "block",
height: "100%",
background: gaugeFillBg[level],
@@ -347,6 +369,4 @@ export const refinedStyles = (tokens: StatusPageThemeTokens, isDark: boolean) =>
},
},
};
-
- return sx;
};
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
index 2a442405c..ebd275ddc 100644
--- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
@@ -30,7 +30,7 @@ const classify = (check: CheckSnapshot): Exclude => {
export const ThemedHeatmap = ({ checks, containerSx, cellSx, classPrefix }: Props) => {
const { t } = useTranslation();
- const useSxApi = Boolean(cellSx);
+ const useSxApi = Boolean(containerSx && cellSx);
const source = checks.slice(0, CELLS).reverse();
const padded: (CheckSnapshot | null)[] = [
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
index 65027731b..af73095df 100644
--- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
@@ -36,7 +36,7 @@ export const ThemedHistogram = ({
statsGap = 1,
}: Props) => {
const { t } = useTranslation();
- const useSxApi = Boolean(barSx);
+ const useSxApi = Boolean(containerSx && barSx && statsSx);
const { padded, max, avg, peak } = useMemo(() => {
const source = checks.slice(0, CELLS).reverse();
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx
index d4334e959..8c642a344 100644
--- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx
@@ -118,8 +118,8 @@ export const ThemedInfrastructure = ({ monitor, sxApi, classPrefix }: Props) =>
return (
{gauges.map((g) => {
- const heatClass =
- g.value > 85 ? `${classPrefix}-hot` : g.value > 70 ? `${classPrefix}-warm` : "";
+ const level = heatLevel(g.value);
+ const heatClass = level === "ok" ? "" : `${classPrefix}-${level}`;
return (
+ tone === "down" ? t.down : tone === "warn" ? t.warn : t.up;
+
+export const toneSoft = (tone: OverallTone, t: StatusPageThemeTokens): string =>
+ tone === "down" ? t.downSoft : tone === "warn" ? t.warnSoft : t.upSoft;
+
export interface OverallStatus {
tone: OverallTone;
message: string;
From c6c50063d848914b2a7bd0831e65d0f6def2dd3e Mon Sep 17 00:00:00 2001
From: gorkem-bwl
Date: Mon, 27 Apr 2026 18:39:42 -0400
Subject: [PATCH 04/19] refactor(status-page): convert bold, modern and
editorial themes to MUI sx
Finishes the migration started for refined. Each theme now consumes the same pattern:
- A typed *Styles interface that names every entry the page consumes (no `as Function` casts at the call site)
- A *Styles factory that takes (tokens, isDark) and returns sx recipes derived from existing tokens.ts plus the shared toneColor/toneSoft helpers in shared/overallStatus
- A useMemo around the factory call so MUI's sx processor sees referential equality across renders that don't change tokens
- modern.css's keyframes (pulse + staggered fade-in + reduced-motion guard) ported via @mui/system keyframes inside the modern styles factory
- bold.css's hero glow + conic logo + pseudo-elements ported as inline sx via "&::before" / "& > *" selectors
- editorial.css's serif typography + hairline dividers + flat rectangular badges ported as plain sx with the serif/sans family stacks named at module scope
Drop the four .css files (~1789 lines) along with the className= attributes and the import "./*.css" lines in each StatusPage component.
The shared themed components (ThemedHeatmap, ThemedHistogram, ThemedInfrastructure) lose their legacy classPrefix branch entirely now that no theme uses it. The dual-API gate is gone; there is one path.
Verified: tsc --noEmit clean, prettier clean.
Note: theme-overrides.css remains for the legacy non-themed status page components (StatusBar, MonitorsList, HeaderStatusPageControls). That's separate scope from the four-theme work the reviewer flagged; can be migrated in a follow-up.
---
.../Status/themes/bold/BoldStatusPage.tsx | 124 +++--
.../StatusPage/Status/themes/bold/bold.css | 497 -----------------
.../StatusPage/Status/themes/bold/styles.ts | 408 ++++++++++++++
.../themes/editorial/EditorialStatusPage.tsx | 121 ++--
.../Status/themes/editorial/editorial.css | 353 ------------
.../Status/themes/editorial/styles.ts | 335 +++++++++++
.../Status/themes/modern/ModernStatusPage.tsx | 121 ++--
.../Status/themes/modern/modern.css | 519 ------------------
.../StatusPage/Status/themes/modern/styles.ts | 431 +++++++++++++++
.../Status/themes/shared/ThemedHeatmap.tsx | 25 +-
.../Status/themes/shared/ThemedHistogram.tsx | 49 +-
.../themes/shared/ThemedInfrastructure.tsx | 75 +--
12 files changed, 1442 insertions(+), 1616 deletions(-)
delete mode 100644 client/src/Pages/StatusPage/Status/themes/bold/bold.css
create mode 100644 client/src/Pages/StatusPage/Status/themes/bold/styles.ts
delete mode 100644 client/src/Pages/StatusPage/Status/themes/editorial/editorial.css
create mode 100644 client/src/Pages/StatusPage/Status/themes/editorial/styles.ts
delete mode 100644 client/src/Pages/StatusPage/Status/themes/modern/modern.css
create mode 100644 client/src/Pages/StatusPage/Status/themes/modern/styles.ts
diff --git a/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx
index ad777dbff..feb17d917 100644
--- a/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
@@ -14,7 +14,8 @@ import {
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
-import "./bold.css";
+import { useStatusPageTheme } from "../StatusPageThemeProvider";
+import { boldStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
@@ -23,85 +24,99 @@ interface Props {
monitors: StatusPageMonitor[];
}
-const PREFIX = "bo";
-
export const BoldStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
+ const { tokens, mode } = useStatusPageTheme();
+ const styles = useMemo(() => boldStyles(tokens, mode === "dark"), [tokens, mode]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
- // Bold uses a dedicated check-disc treatment next to the title; its
- // icon is scaled differently but the semantic resolution is shared.
const overall = resolveOverallStatus(monitors, t, { iconSize: 18 });
const logoSrc = statusPage.logo?.data
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
return (
-
+
-
+
{logoSrc ? (
) : (
-
- {monoFirstChar(statusPage.companyName)}
-
+ {monoFirstChar(statusPage.companyName)}
)}
- {statusPage.companyName}
+
+ {statusPage.companyName}
+
-
-
- {overall.icon}
+
+
+
+ {overall.icon}
+
{overall.message}
-
-
+
+
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
-
+
{statusPage.showCharts && (
-
+
- setChartMode("heatmap")}
+ sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
-
-
+ setChartMode("histogram")}
+ sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
-
+
)}
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
@@ -113,48 +128,65 @@ export const BoldStatusPage = ({ statusPage, monitors }: Props) => {
-
-
- {monitor.name}
-
-
+
+ {monitor.name}
+
+
{getMonitorTypeLabel(monitor.type, t)}
-
+
{monitor.url && (
-
{monitor.url}
-
+
)}
-
+
{t(statusBadgeKey[monitor.status])}
-
+
{showInfra && (
)}
{showChart &&
(chartMode === "heatmap" ? (
) : (
))}
@@ -164,7 +196,7 @@ export const BoldStatusPage = ({ statusPage, monitors }: Props) => {
{t("pages.statusPages.footer.poweredBy")}{" "}
* {
- position: relative;
- z-index: 1;
-}
-[data-status-theme="bold"] .bo-hero-title {
- margin: 0;
- font-size: 34px;
- font-weight: 800;
- letter-spacing: -0.02em;
- line-height: 1.1;
- color: var(--sp-text);
- display: flex;
- align-items: center;
- gap: 10px;
-}
-[data-status-theme="bold"] .bo-hero-check {
- display: inline-grid;
- place-items: center;
- width: 32px;
- height: 32px;
- border-radius: 50%;
- background: var(--sp-up);
- color: #05070a;
- flex-shrink: 0;
- box-shadow: 0 0 0 6px var(--sp-up-soft);
-}
-[data-status-theme="bold"] .bo-hero.bo-tone-warn .bo-hero-check {
- background: var(--sp-warn);
- box-shadow: 0 0 0 6px var(--sp-warn-soft);
-}
-[data-status-theme="bold"] .bo-hero.bo-tone-down .bo-hero-check {
- background: var(--sp-down);
- color: #fff;
- box-shadow: 0 0 0 6px var(--sp-down-soft);
-}
-[data-status-theme="bold"] .bo-hero-sub {
- margin: 10px 0 0;
- color: var(--sp-text-muted);
- font-size: 14px;
-}
-
-/* --- chart toggle --- */
-[data-status-theme="bold"] .bo-chart-switch-wrap {
- display: flex;
- justify-content: flex-end;
- margin-bottom: 16px;
-}
-[data-status-theme="bold"] .bo-chart-switch {
- display: inline-flex;
- border: 1px solid var(--sp-border);
- border-radius: 999px;
- background: var(--sp-surface);
- padding: 3px;
- gap: 2px;
-}
-[data-status-theme="bold"] .bo-chart-switch > button {
- border: 0;
- background: transparent;
- font: inherit;
- font-size: 11px;
- padding: 8px 18px;
- cursor: pointer;
- color: var(--sp-text-muted);
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.12em;
- transition:
- background 0.2s,
- color 0.2s;
- border-radius: 999px;
-}
-[data-status-theme="bold"] .bo-chart-switch > button:hover {
- color: var(--sp-text);
-}
-[data-status-theme="bold"] .bo-chart-switch > button.bo-active {
- color: var(--sp-up);
- background: var(--sp-up-soft);
-}
-
-/* --- cards: 18px radius, staggered fade-in, accent-green border on hover --- */
-[data-status-theme="bold"] .bo-monitor-list {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
- gap: 14px;
-}
-[data-status-theme="bold"] .bo-card {
- background: var(--sp-surface);
- border: 1px solid var(--sp-border);
- border-radius: 18px;
- overflow: hidden;
- transition:
- transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1),
- border-color 0.2s;
-}
-[data-status-theme="bold"] .bo-card:hover {
- transform: translateY(-3px);
- border-color: color-mix(in srgb, var(--sp-up) 40%, var(--sp-border));
-}
-
-[data-status-theme="bold"] .bo-card-row {
- display: grid;
- grid-template-columns: 1fr auto;
- align-items: center;
- gap: 24px;
- padding: 22px 28px;
-}
-[data-status-theme="bold"] .bo-card-left {
- min-width: 0;
-}
-[data-status-theme="bold"] .bo-monitor-name {
- font-weight: 700;
- font-size: 17px;
- letter-spacing: -0.01em;
- color: var(--sp-text);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-[data-status-theme="bold"] .bo-monitor-meta {
- display: flex;
- gap: 10px;
- align-items: center;
- margin-top: 6px;
- flex-wrap: wrap;
-}
-[data-status-theme="bold"] .bo-pill {
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.1em;
- color: var(--sp-text-muted);
- background: color-mix(in srgb, var(--sp-surface) 80%, var(--sp-bg));
- border: 1px solid var(--sp-border);
- padding: 3px 10px;
- border-radius: 999px;
- font-weight: 700;
-}
-[data-status-theme="bold"] .bo-pill-hardware {
- color: var(--sp-up);
- border-color: color-mix(in srgb, var(--sp-up) 40%, transparent);
- background: var(--sp-up-soft);
-}
-[data-status-theme="bold"] .bo-monitor-url {
- font-size: 12px;
- color: var(--sp-text-muted);
- font-family: ui-monospace, Menlo, monospace;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 300px;
-}
-
-/* --- badge: glowing dot --- */
-[data-status-theme="bold"] .bo-badge {
- font-size: 11px;
- font-weight: 800;
- padding: 6px 14px;
- border-radius: 999px;
- display: inline-flex;
- align-items: center;
- gap: 7px;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- white-space: nowrap;
-}
-[data-status-theme="bold"] .bo-badge::before {
- content: "";
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: currentColor;
- box-shadow: 0 0 8px currentColor;
-}
-[data-status-theme="bold"] .bo-badge-up {
- background: var(--sp-up-soft);
- color: var(--sp-up);
-}
-[data-status-theme="bold"] .bo-badge-warn {
- background: var(--sp-warn-soft);
- color: var(--sp-warn);
-}
-[data-status-theme="bold"] .bo-badge-down {
- background: var(--sp-down-soft);
- color: var(--sp-down);
-}
-
-/* --- heatmap: glowing cells --- */
-[data-status-theme="bold"] .bo-heatmap {
- padding: 0 28px 22px;
- display: grid;
- grid-template-columns: repeat(25, 1fr);
- gap: 3px;
- height: 48px;
-}
-[data-status-theme="bold"] .bo-heatmap-cell {
- border-radius: 3px;
- background: var(--sp-up);
- transition: transform 0.15s;
- box-shadow: 0 0 6px color-mix(in srgb, var(--sp-up) 18%, transparent);
-}
-[data-status-theme="bold"] .bo-heatmap-cell:hover {
- transform: scaleY(1.2);
-}
-[data-status-theme="bold"] .bo-heatmap-cell.bo-fast {
- background: var(--sp-up);
-}
-[data-status-theme="bold"] .bo-heatmap-cell.bo-med {
- background: color-mix(in srgb, var(--sp-up) 70%, #ffffff 30%);
-}
-[data-status-theme="bold"] .bo-heatmap-cell.bo-slow {
- background: var(--sp-warn);
- box-shadow: 0 0 6px color-mix(in srgb, var(--sp-warn) 20%, transparent);
-}
-[data-status-theme="bold"] .bo-heatmap-cell.bo-down {
- background: var(--sp-down);
- box-shadow: 0 0 6px color-mix(in srgb, var(--sp-down) 20%, transparent);
-}
-[data-status-theme="bold"] .bo-heatmap-cell.bo-empty {
- background: var(--sp-border);
- box-shadow: none;
- opacity: 0.4;
-}
-
-/* --- histogram: glowing gradient bars --- */
-[data-status-theme="bold"] .bo-histogram {
- padding: 0 28px;
- display: grid;
- grid-template-columns: repeat(25, 1fr);
- gap: 3px;
- align-items: flex-end;
- height: 48px;
-}
-[data-status-theme="bold"] .bo-bar {
- background: var(--sp-up);
- border-radius: 3px;
- min-height: 4px;
-}
-[data-status-theme="bold"] .bo-bar.bo-down {
- background: var(--sp-down);
-}
-[data-status-theme="bold"] .bo-bar.bo-empty {
- background: var(--sp-border);
- box-shadow: none;
- opacity: 0.4;
-}
-[data-status-theme="bold"] .bo-chart-stats {
- padding: 0 28px 22px;
- font-size: 11px;
- color: var(--sp-text-muted);
- font-variant-numeric: tabular-nums;
- font-weight: 700;
- letter-spacing: 0.05em;
-}
-
-/* --- gauges: larger, bolder, 16px radius --- */
-[data-status-theme="bold"] .bo-infra {
- padding: 18px 28px 26px;
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 16px;
-}
-@media (max-width: 720px) {
- [data-status-theme="bold"] .bo-infra {
- grid-template-columns: 1fr;
- }
-}
-[data-status-theme="bold"] .bo-infra-empty {
- padding: 18px 28px 26px;
- color: var(--sp-text-muted);
- font-size: 13px;
-}
-[data-status-theme="bold"] .bo-gauge {
- border: 1px solid var(--sp-border);
- border-radius: 16px;
- padding: 16px 18px;
- background: color-mix(in srgb, var(--sp-surface) 80%, var(--sp-bg));
-}
-[data-status-theme="bold"] .bo-gauge-label {
- font-size: 10px;
- color: var(--sp-text-muted);
- text-transform: uppercase;
- letter-spacing: 0.12em;
- font-weight: 700;
-}
-[data-status-theme="bold"] .bo-gauge-value {
- font-size: 30px;
- font-weight: 800;
- letter-spacing: -0.02em;
- margin-top: 6px;
- font-variant-numeric: tabular-nums;
- color: var(--sp-text);
-}
-[data-status-theme="bold"] .bo-gauge-bar {
- height: 6px;
- background: var(--sp-border);
- border-radius: 3px;
- overflow: hidden;
- margin-top: 12px;
-}
-[data-status-theme="bold"] .bo-gauge-fill {
- display: block;
- height: 100%;
- background: linear-gradient(
- 90deg,
- var(--sp-up),
- color-mix(in srgb, var(--sp-up) 70%, #ffffff 30%)
- );
- border-radius: 3px;
- transition: width 0.9s cubic-bezier(0.2, 0.8, 0.2, 1);
-}
-[data-status-theme="bold"] .bo-gauge-fill.bo-warm {
- background: linear-gradient(90deg, var(--sp-warn), #fbbf24);
-}
-[data-status-theme="bold"] .bo-gauge-fill.bo-hot {
- background: linear-gradient(90deg, var(--sp-down), #fb7185);
-}
-[data-status-theme="bold"] .bo-gauge-sub {
- font-size: 11px;
- color: var(--sp-text-muted);
- margin-top: 10px;
- font-variant-numeric: tabular-nums;
-}
-
-/* --- footer --- */
-[data-status-theme="bold"] .bo-footer {
- text-align: center;
- color: var(--sp-text-muted);
- font-size: 12px;
- margin-top: 52px;
-}
-[data-status-theme="bold"] .bo-footer a {
- color: var(--sp-up);
- text-decoration: underline;
- text-underline-offset: 3px;
- font-weight: 800;
-}
diff --git a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts
new file mode 100644
index 000000000..50f8c9cf0
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts
@@ -0,0 +1,408 @@
+import type { SxProps, Theme } from "@mui/material/styles";
+import type { StatusPageThemeTokens } from "../tokens";
+import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
+
+export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty";
+export type BoldBarKind = "up" | "down" | "empty";
+export type BoldGaugeFill = "ok" | "warm" | "hot";
+
+export interface BoldStyles {
+ page: SxProps;
+ top: SxProps;
+ brand: SxProps;
+ logoConic: SxProps;
+ logoImg: SxProps;
+ company: SxProps;
+ hero: SxProps;
+ heroTitle: SxProps;
+ heroCheck: (tone: OverallTone) => SxProps;
+ heroSub: SxProps;
+ chartSwitchWrap: SxProps;
+ chartSwitch: SxProps;
+ chartSwitchButton: (active: boolean) => SxProps;
+ monitorList: SxProps;
+ card: SxProps;
+ cardRow: SxProps;
+ cardLeft: SxProps;
+ monitorName: SxProps;
+ monitorMeta: SxProps;
+ pill: SxProps;
+ pillHardware: SxProps;
+ monitorUrl: SxProps;
+ badge: (tone: OverallTone) => SxProps;
+ heatmap: SxProps;
+ heatmapCell: (kind: BoldHeatCell) => SxProps;
+ histogram: SxProps;
+ bar: (kind: BoldBarKind, heightPct: number) => SxProps;
+ chartStats: SxProps;
+ infra: SxProps;
+ infraEmpty: SxProps;
+ gauge: SxProps;
+ gaugeLabel: SxProps;
+ gaugeValue: SxProps;
+ gaugeBar: SxProps;
+ gaugeFill: (level: BoldGaugeFill, widthPct: number) => SxProps;
+ gaugeSub: SxProps;
+ footer: SxProps;
+}
+
+const sansFontStack =
+ 'ui-sans-serif, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
+
+export const boldStyles = (
+ tokens: StatusPageThemeTokens,
+ isDark: boolean
+): BoldStyles => {
+ const heatCellBg: Record = {
+ fast: tokens.up,
+ med: `color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%)`,
+ slow: tokens.warn,
+ down: tokens.down,
+ empty: tokens.border,
+ };
+ const heatCellShadow: Record = {
+ fast: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
+ med: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`,
+ slow: `0 0 6px color-mix(in srgb, ${tokens.warn} 20%, transparent)`,
+ down: `0 0 6px color-mix(in srgb, ${tokens.down} 20%, transparent)`,
+ empty: "none",
+ };
+
+ const barBg: Record = {
+ up: tokens.up,
+ down: tokens.down,
+ empty: tokens.border,
+ };
+
+ const gaugeFillBg: Record = {
+ ok: `linear-gradient(90deg, ${tokens.up}, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%))`,
+ warm: `linear-gradient(90deg, ${tokens.warn}, #fbbf24)`,
+ hot: `linear-gradient(90deg, ${tokens.down}, #fb7185)`,
+ };
+
+ const heroBgLight = `linear-gradient(135deg, color-mix(in srgb, ${tokens.up} 8%, transparent), transparent 60%), ${tokens.surface}`;
+ const heroBgDark = `linear-gradient(135deg, color-mix(in srgb, ${tokens.up} 16%, transparent), transparent 60%), ${tokens.surface}`;
+
+ const heroGlowLight = `radial-gradient(400px 200px at 20% 30%, color-mix(in srgb, ${tokens.up} 14%, transparent), transparent 60%), radial-gradient(300px 200px at 90% 80%, color-mix(in srgb, ${tokens.upStrong} 10%, transparent), transparent 60%)`;
+ const heroGlowDark = `radial-gradient(400px 200px at 20% 30%, color-mix(in srgb, ${tokens.up} 32%, transparent), transparent 60%), radial-gradient(300px 200px at 90% 80%, color-mix(in srgb, ${tokens.upStrong} 22%, transparent), transparent 60%)`;
+
+ return {
+ page: {
+ flex: "1 0 auto",
+ maxWidth: 1040,
+ width: "100%",
+ mx: "auto",
+ p: "56px 24px 96px",
+ fontFamily: sansFontStack,
+ fontSize: 14,
+ lineHeight: 1.55,
+ color: tokens.text,
+ WebkitFontSmoothing: "antialiased",
+ },
+
+ top: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ mb: "36px",
+ },
+ brand: {
+ display: "flex",
+ alignItems: "center",
+ gap: "14px",
+ fontWeight: 700,
+ fontSize: 16,
+ letterSpacing: "-0.01em",
+ color: tokens.text,
+ },
+ logoConic: {
+ width: 36,
+ height: 36,
+ borderRadius: "12px",
+ background: `conic-gradient(from 140deg, ${tokens.up}, ${tokens.upStrong}, #0f766e, ${tokens.up})`,
+ display: "grid",
+ placeItems: "center",
+ color: "#05070a",
+ fontWeight: 900,
+ fontSize: 14,
+ boxShadow: `0 8px 24px color-mix(in srgb, ${tokens.up} 30%, transparent), inset 0 1px 0 rgba(255, 255, 255, 0.2)`,
+ },
+ logoImg: { maxHeight: 36, maxWidth: 140, objectFit: "contain" },
+ company: {},
+
+ hero: {
+ position: "relative",
+ borderRadius: "22px",
+ padding: "36px 36px 32px",
+ mb: "28px",
+ background: isDark ? heroBgDark : heroBgLight,
+ border: `1px solid ${tokens.border}`,
+ overflow: "hidden",
+ "&::before": {
+ content: '""',
+ position: "absolute",
+ inset: 0,
+ background: isDark ? heroGlowDark : heroGlowLight,
+ pointerEvents: "none",
+ opacity: 0.8,
+ zIndex: 0,
+ ...(isDark ? { mixBlendMode: "screen" } : {}),
+ },
+ "& > *": { position: "relative", zIndex: 1 },
+ },
+ heroTitle: {
+ m: 0,
+ fontSize: 34,
+ fontWeight: 800,
+ letterSpacing: "-0.02em",
+ lineHeight: 1.1,
+ color: tokens.text,
+ display: "flex",
+ alignItems: "center",
+ gap: "10px",
+ },
+ heroCheck: (tone) => ({
+ display: "inline-grid",
+ placeItems: "center",
+ width: 32,
+ height: 32,
+ borderRadius: "50%",
+ background: toneColor(tone, tokens),
+ color: tone === "down" ? "#fff" : "#05070a",
+ flexShrink: 0,
+ boxShadow: `0 0 0 6px ${toneSoft(tone, tokens)}`,
+ }),
+ heroSub: { mt: "10px", mb: 0, color: tokens.textMuted, fontSize: 14 },
+
+ chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "16px" },
+ chartSwitch: {
+ display: "inline-flex",
+ border: `1px solid ${tokens.border}`,
+ borderRadius: "999px",
+ background: tokens.surface,
+ p: "3px",
+ gap: "2px",
+ },
+ chartSwitchButton: (active) => ({
+ border: 0,
+ background: active ? tokens.upSoft : "transparent",
+ fontFamily: "inherit",
+ fontSize: 11,
+ padding: "8px 18px",
+ cursor: "pointer",
+ color: active ? tokens.up : tokens.textMuted,
+ fontWeight: 700,
+ textTransform: "uppercase",
+ letterSpacing: "0.12em",
+ transition: "background 0.2s, color 0.2s",
+ borderRadius: "999px",
+ "&:hover": { color: active ? tokens.up : tokens.text },
+ }),
+
+ monitorList: {
+ listStyle: "none",
+ m: 0,
+ p: 0,
+ display: "flex",
+ flexDirection: "column",
+ gap: "14px",
+ },
+ card: {
+ background: tokens.surface,
+ border: `1px solid ${tokens.border}`,
+ borderRadius: "18px",
+ overflow: "hidden",
+ transition: "transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), border-color 0.2s",
+ "&:hover": {
+ transform: "translateY(-3px)",
+ borderColor: `color-mix(in srgb, ${tokens.up} 40%, ${tokens.border})`,
+ },
+ },
+ cardRow: {
+ display: "grid",
+ gridTemplateColumns: "1fr auto",
+ alignItems: "center",
+ gap: "24px",
+ p: "22px 28px",
+ },
+ cardLeft: { minWidth: 0 },
+ monitorName: {
+ fontWeight: 700,
+ fontSize: 17,
+ letterSpacing: "-0.01em",
+ color: tokens.text,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ },
+ monitorMeta: {
+ display: "flex",
+ gap: "10px",
+ alignItems: "center",
+ mt: "6px",
+ flexWrap: "wrap",
+ },
+ pill: {
+ fontSize: 10,
+ textTransform: "uppercase",
+ letterSpacing: "0.1em",
+ color: tokens.textMuted,
+ background: `color-mix(in srgb, ${tokens.surface} 80%, ${tokens.bg})`,
+ border: `1px solid ${tokens.border}`,
+ padding: "3px 10px",
+ borderRadius: "999px",
+ fontWeight: 700,
+ },
+ pillHardware: {
+ fontSize: 10,
+ textTransform: "uppercase",
+ letterSpacing: "0.1em",
+ padding: "3px 10px",
+ borderRadius: "999px",
+ fontWeight: 700,
+ color: tokens.up,
+ borderColor: `color-mix(in srgb, ${tokens.up} 40%, transparent)`,
+ border: `1px solid color-mix(in srgb, ${tokens.up} 40%, transparent)`,
+ background: tokens.upSoft,
+ },
+ monitorUrl: {
+ fontSize: 12,
+ color: tokens.textMuted,
+ fontFamily: "ui-monospace, Menlo, monospace",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ maxWidth: 300,
+ },
+
+ badge: (tone) => ({
+ fontSize: 11,
+ fontWeight: 800,
+ padding: "6px 14px",
+ borderRadius: "999px",
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "7px",
+ textTransform: "uppercase",
+ letterSpacing: "0.08em",
+ whiteSpace: "nowrap",
+ background: toneSoft(tone, tokens),
+ color: toneColor(tone, tokens),
+ "&::before": {
+ content: '""',
+ width: 6,
+ height: 6,
+ borderRadius: "50%",
+ background: "currentColor",
+ boxShadow: "0 0 8px currentColor",
+ },
+ }),
+
+ heatmap: {
+ padding: "0 28px 22px",
+ display: "grid",
+ gridTemplateColumns: "repeat(25, 1fr)",
+ gap: "3px",
+ height: 48,
+ },
+ heatmapCell: (kind) => ({
+ borderRadius: "3px",
+ background: heatCellBg[kind],
+ boxShadow: heatCellShadow[kind],
+ opacity: kind === "empty" ? 0.4 : 1,
+ transition: "transform 0.15s",
+ "&:hover": { transform: "scaleY(1.2)" },
+ }),
+
+ histogram: {
+ padding: "0 28px",
+ display: "grid",
+ gridTemplateColumns: "repeat(25, 1fr)",
+ gap: "3px",
+ alignItems: "flex-end",
+ height: 48,
+ },
+ bar: (kind, heightPct) => ({
+ background: barBg[kind],
+ borderRadius: "3px",
+ minHeight: 4,
+ opacity: kind === "empty" ? 0.4 : 1,
+ height: `${heightPct}%`,
+ }),
+ chartStats: {
+ padding: "0 28px 22px",
+ fontSize: 11,
+ color: tokens.textMuted,
+ fontVariantNumeric: "tabular-nums",
+ fontWeight: 700,
+ letterSpacing: "0.05em",
+ },
+
+ infra: {
+ padding: "18px 28px 26px",
+ display: "grid",
+ gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
+ gap: "16px",
+ },
+ infraEmpty: {
+ padding: "18px 28px 26px",
+ color: tokens.textMuted,
+ fontSize: 13,
+ },
+ gauge: {
+ border: `1px solid ${tokens.border}`,
+ borderRadius: "16px",
+ p: "16px 18px",
+ background: `color-mix(in srgb, ${tokens.surface} 80%, ${tokens.bg})`,
+ },
+ gaugeLabel: {
+ fontSize: 10,
+ color: tokens.textMuted,
+ textTransform: "uppercase",
+ letterSpacing: "0.12em",
+ fontWeight: 700,
+ },
+ gaugeValue: {
+ fontSize: 30,
+ fontWeight: 800,
+ letterSpacing: "-0.02em",
+ mt: "6px",
+ fontVariantNumeric: "tabular-nums",
+ color: tokens.text,
+ },
+ gaugeBar: {
+ height: 6,
+ background: tokens.border,
+ borderRadius: "3px",
+ overflow: "hidden",
+ mt: "12px",
+ },
+ gaugeFill: (level, widthPct) => ({
+ display: "block",
+ height: "100%",
+ background: gaugeFillBg[level],
+ borderRadius: "3px",
+ transition: "width 0.9s cubic-bezier(0.2, 0.8, 0.2, 1)",
+ width: `${Math.max(0, Math.min(100, widthPct))}%`,
+ }),
+ gaugeSub: {
+ fontSize: 11,
+ color: tokens.textMuted,
+ mt: "10px",
+ fontVariantNumeric: "tabular-nums",
+ },
+
+ footer: {
+ textAlign: "center",
+ color: tokens.textMuted,
+ fontSize: 12,
+ mt: "52px",
+ "& a": {
+ color: tokens.up,
+ textDecoration: "underline",
+ textUnderlineOffset: "3px",
+ fontWeight: 800,
+ },
+ },
+ };
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx
index 306ae2bc0..e6c57a5bf 100644
--- a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx
@@ -13,7 +13,8 @@ import {
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
-import "./editorial.css";
+import { useStatusPageTheme } from "../StatusPageThemeProvider";
+import { editorialStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
@@ -22,14 +23,12 @@ interface Props {
monitors: StatusPageMonitor[];
}
-const PREFIX = "ed";
-
export const EditorialStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
+ const { tokens, mode } = useStatusPageTheme();
+ const styles = useMemo(() => editorialStyles(tokens, mode === "dark"), [tokens, mode]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
- // Stable date across re-renders; the page polls every 10s and we don't
- // want the dateline to jitter.
const todayLabel = useMemo(
() =>
new Date().toLocaleDateString(undefined, {
@@ -48,69 +47,88 @@ export const EditorialStatusPage = ({ statusPage, monitors }: Props) => {
: null;
return (
-
+
-
+
{logoSrc && (
)}
- {statusPage.companyName}
-
+
+ {statusPage.companyName}
+
+
{t("pages.statusPages.editorial.reportTitle")}
-
+
-
-
+
+
{overall.message}
-
-
+
+
{todayLabel} ·{" "}
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
-
+
{statusPage.showCharts && (
-
+
- setChartMode("heatmap")}
+ sx={styles.chartSwitchButton(chartMode === "heatmap", true)}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
-
-
+ setChartMode("histogram")}
+ sx={styles.chartSwitchButton(chartMode === "histogram", false)}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
-
+
)}
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
@@ -122,48 +140,65 @@ export const EditorialStatusPage = ({ statusPage, monitors }: Props) => {
-
-
- {monitor.name}
-
-
+
+ {monitor.name}
+
+
{getMonitorTypeLabel(monitor.type, t)}
-
+
{monitor.url && (
-
{monitor.url}
-
+
)}
-
+
{t(statusBadgeKey[monitor.status])}
-
+
{showInfra && (
)}
{showChart &&
(chartMode === "heatmap" ? (
) : (
))}
@@ -173,7 +208,7 @@ export const EditorialStatusPage = ({ statusPage, monitors }: Props) => {
{t("pages.statusPages.footer.poweredBy")}{" "}
button {
- font: inherit;
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.2em;
- padding: 6px 14px;
- border: 0;
- background: transparent;
- color: var(--sp-text);
- cursor: pointer;
- font-weight: 700;
-}
-[data-status-theme="editorial"] .ed-chart-switch > button + button {
- border-left: 1px solid var(--sp-text);
-}
-[data-status-theme="editorial"] .ed-chart-switch > button.ed-active {
- background: var(--sp-text);
- color: var(--sp-bg);
-}
-
-/* --- monitor cards: transparent rows with bottom hairline --- */
-[data-status-theme="editorial"] .ed-monitor-list {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
-}
-[data-status-theme="editorial"] .ed-card {
- background: transparent;
- border: 0;
- border-bottom: 1px solid var(--sp-border);
- margin-bottom: 0;
- padding: 20px 0;
-}
-[data-status-theme="editorial"] .ed-card:last-of-type {
- border-bottom: 0;
-}
-
-[data-status-theme="editorial"] .ed-card-row {
- display: grid;
- grid-template-columns: 1fr auto;
- align-items: baseline;
- gap: 20px;
-}
-[data-status-theme="editorial"] .ed-card-left {
- min-width: 0;
-}
-[data-status-theme="editorial"] .ed-monitor-name {
- font-size: 20px;
- font-weight: 700;
- letter-spacing: -0.015em;
- color: var(--sp-text);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-[data-status-theme="editorial"] .ed-monitor-meta {
- font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
- font-size: 12px;
- color: var(--sp-text-muted);
- margin-top: 6px;
- display: flex;
- gap: 12px;
- flex-wrap: wrap;
- align-items: center;
-}
-[data-status-theme="editorial"] .ed-pill {
- text-transform: uppercase;
- letter-spacing: 0.12em;
- font-weight: 700;
- color: var(--sp-text-muted);
-}
-[data-status-theme="editorial"] .ed-pill-hardware {
- color: var(--sp-up);
-}
-[data-status-theme="editorial"] .ed-monitor-url {
- font-family: ui-monospace, Menlo, monospace;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 280px;
- font-size: 12px;
-}
-
-/* --- badge: rectangular flat label --- */
-[data-status-theme="editorial"] .ed-badge {
- font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
- font-size: 10px;
- font-weight: 700;
- padding: 4px 10px;
- border-radius: 0;
- text-transform: uppercase;
- letter-spacing: 0.15em;
- white-space: nowrap;
-}
-[data-status-theme="editorial"] .ed-badge-up {
- background: var(--sp-up-soft);
- color: var(--sp-up);
-}
-[data-status-theme="editorial"] .ed-badge-warn {
- background: var(--sp-warn-soft);
- color: var(--sp-warn);
-}
-[data-status-theme="editorial"] .ed-badge-down {
- background: var(--sp-down-soft);
- color: var(--sp-down);
-}
-
-/* --- charts: flat rectangles, no glow --- */
-[data-status-theme="editorial"] .ed-heatmap {
- margin-top: 16px;
- display: grid;
- grid-template-columns: repeat(25, 1fr);
- gap: 2px;
- height: 40px;
-}
-[data-status-theme="editorial"] .ed-heatmap-cell {
- background: var(--sp-up);
-}
-[data-status-theme="editorial"] .ed-heatmap-cell.ed-fast {
- background: var(--sp-up);
-}
-[data-status-theme="editorial"] .ed-heatmap-cell.ed-med {
- background: color-mix(in srgb, var(--sp-up) 60%, var(--sp-bg));
-}
-[data-status-theme="editorial"] .ed-heatmap-cell.ed-slow {
- background: var(--sp-warn);
-}
-[data-status-theme="editorial"] .ed-heatmap-cell.ed-down {
- background: var(--sp-down);
-}
-[data-status-theme="editorial"] .ed-heatmap-cell.ed-empty {
- background: var(--sp-border);
- opacity: 0.5;
-}
-
-[data-status-theme="editorial"] .ed-histogram {
- margin-top: 16px;
- display: grid;
- grid-template-columns: repeat(25, 1fr);
- gap: 2px;
- align-items: flex-end;
- height: 40px;
-}
-[data-status-theme="editorial"] .ed-bar {
- background: var(--sp-up);
- min-height: 3px;
-}
-[data-status-theme="editorial"] .ed-bar.ed-down {
- background: var(--sp-down);
-}
-[data-status-theme="editorial"] .ed-bar.ed-empty {
- background: var(--sp-border);
- opacity: 0.5;
-}
-[data-status-theme="editorial"] .ed-chart-stats {
- font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
- font-size: 11px;
- color: var(--sp-text-muted);
- font-variant-numeric: tabular-nums;
- text-transform: uppercase;
- letter-spacing: 0.1em;
-}
-
-/* --- gauges: 3-column with serif value + 2px underline bar --- */
-[data-status-theme="editorial"] .ed-infra {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 24px;
- margin-top: 20px;
- font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
-}
-@media (max-width: 720px) {
- [data-status-theme="editorial"] .ed-infra {
- grid-template-columns: 1fr;
- }
-}
-[data-status-theme="editorial"] .ed-infra-empty {
- margin-top: 20px;
- color: var(--sp-text-muted);
- font-size: 13px;
-}
-[data-status-theme="editorial"] .ed-gauge-label {
- font-size: 10px;
- color: var(--sp-text-muted);
- text-transform: uppercase;
- letter-spacing: 0.18em;
- font-weight: 700;
-}
-[data-status-theme="editorial"] .ed-gauge-value {
- font-size: 28px;
- font-weight: 700;
- letter-spacing: -0.02em;
- margin-top: 4px;
- font-variant-numeric: tabular-nums;
- font-family: Georgia, serif;
- color: var(--sp-text);
-}
-[data-status-theme="editorial"] .ed-gauge-bar {
- height: 2px;
- background: var(--sp-border);
- margin-top: 10px;
-}
-[data-status-theme="editorial"] .ed-gauge-fill {
- display: block;
- height: 100%;
- background: var(--sp-up);
- transition: width 0.8s;
-}
-[data-status-theme="editorial"] .ed-gauge-fill.ed-warm {
- background: var(--sp-warn);
-}
-[data-status-theme="editorial"] .ed-gauge-fill.ed-hot {
- background: var(--sp-down);
-}
-[data-status-theme="editorial"] .ed-gauge-sub {
- font-size: 11px;
- color: var(--sp-text-muted);
- margin-top: 8px;
- font-variant-numeric: tabular-nums;
-}
-
-/* --- footer: tiny uppercase tracked + underlined link --- */
-[data-status-theme="editorial"] .ed-footer {
- text-align: center;
- color: var(--sp-text-muted);
- font-size: 11px;
- margin-top: 56px;
- text-transform: uppercase;
- letter-spacing: 0.2em;
- font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
-}
-[data-status-theme="editorial"] .ed-footer a {
- color: var(--sp-text);
- text-decoration: none;
- border-bottom: 1px solid var(--sp-text);
- padding-bottom: 2px;
- font-weight: 700;
-}
diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts
new file mode 100644
index 000000000..c3948577e
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts
@@ -0,0 +1,335 @@
+import type { SxProps, Theme } from "@mui/material/styles";
+import type { StatusPageThemeTokens } from "../tokens";
+import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
+
+export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty";
+export type EditorialBarKind = "up" | "down" | "empty";
+export type EditorialGaugeFill = "ok" | "warm" | "hot";
+
+export interface EditorialStyles {
+ page: SxProps;
+ top: SxProps;
+ brandWrap: SxProps;
+ logoImg: SxProps;
+ brandEyebrow: SxProps;
+ brandTitle: SxProps;
+ statusLine: SxProps;
+ statusDot: (tone: OverallTone) => SxProps;
+ dateline: SxProps;
+ chartSwitchWrap: SxProps;
+ chartSwitch: SxProps;
+ chartSwitchButton: (active: boolean, isFirst: boolean) => SxProps;
+ monitorList: SxProps;
+ card: SxProps;
+ cardRow: SxProps;
+ cardLeft: SxProps;
+ monitorName: SxProps;
+ monitorMeta: SxProps;
+ pill: SxProps;
+ pillHardware: SxProps;
+ monitorUrl: SxProps;
+ badge: (tone: OverallTone) => SxProps;
+ heatmap: SxProps;
+ heatmapCell: (kind: EditorialHeatCell) => SxProps;
+ histogram: SxProps;
+ bar: (kind: EditorialBarKind, heightPct: number) => SxProps;
+ chartStats: SxProps;
+ infra: SxProps;
+ infraEmpty: SxProps;
+ gauge: SxProps;
+ gaugeLabel: SxProps;
+ gaugeValue: SxProps;
+ gaugeBar: SxProps;
+ gaugeFill: (level: EditorialGaugeFill, widthPct: number) => SxProps;
+ gaugeSub: SxProps;
+ footer: SxProps;
+}
+
+const serifStack = 'Georgia, "Iowan Old Style", "Palatino Linotype", serif';
+const sansStack = '-apple-system, "Helvetica Neue", Arial, sans-serif';
+
+export const editorialStyles = (
+ tokens: StatusPageThemeTokens,
+ _isDark: boolean
+): EditorialStyles => {
+ const heatCellBg: Record = {
+ fast: tokens.up,
+ med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
+ slow: tokens.warn,
+ down: tokens.down,
+ empty: tokens.border,
+ };
+
+ const barBg: Record = {
+ up: tokens.up,
+ down: tokens.down,
+ empty: tokens.border,
+ };
+
+ const gaugeFillBg: Record = {
+ ok: tokens.up,
+ warm: tokens.warn,
+ hot: tokens.down,
+ };
+
+ return {
+ page: {
+ flex: "1 0 auto",
+ maxWidth: 760,
+ width: "100%",
+ mx: "auto",
+ p: "64px 24px 96px",
+ fontFamily: serifStack,
+ fontSize: 15,
+ lineHeight: 1.6,
+ color: tokens.text,
+ WebkitFontSmoothing: "antialiased",
+ },
+
+ top: {
+ display: "flex",
+ alignItems: "flex-end",
+ justifyContent: "space-between",
+ mb: "48px",
+ borderBottom: `2px solid ${tokens.text}`,
+ pb: "18px",
+ },
+ brandWrap: { display: "flex", flexDirection: "column" },
+ logoImg: { maxHeight: 48, maxWidth: 160, objectFit: "contain", mb: "8px" },
+ brandEyebrow: {
+ m: 0,
+ fontSize: 11,
+ textTransform: "uppercase",
+ letterSpacing: "0.3em",
+ fontWeight: 700,
+ color: tokens.textMuted,
+ fontFamily: sansStack,
+ },
+ brandTitle: {
+ mt: "6px",
+ mb: 0,
+ fontSize: 40,
+ fontWeight: 700,
+ letterSpacing: "-0.02em",
+ lineHeight: 1,
+ color: tokens.text,
+ },
+
+ statusLine: {
+ fontSize: 22,
+ fontWeight: 400,
+ m: 0,
+ mb: "8px",
+ letterSpacing: "-0.01em",
+ lineHeight: 1.35,
+ color: tokens.text,
+ fontFamily: serifStack,
+ },
+ statusDot: (tone) => ({
+ display: "inline-block",
+ width: 10,
+ height: 10,
+ borderRadius: "50%",
+ background: toneColor(tone, tokens),
+ mr: "10px",
+ verticalAlign: "middle",
+ }),
+ dateline: {
+ fontFamily: sansStack,
+ fontSize: 11,
+ textTransform: "uppercase",
+ letterSpacing: "0.12em",
+ color: tokens.textMuted,
+ m: 0,
+ mb: "56px",
+ },
+
+ chartSwitchWrap: {
+ display: "flex",
+ justifyContent: "flex-end",
+ mb: "20px",
+ fontFamily: sansStack,
+ },
+ chartSwitch: { display: "inline-flex", border: `1px solid ${tokens.text}` },
+ chartSwitchButton: (active, isFirst) => ({
+ fontFamily: "inherit",
+ fontSize: 10,
+ textTransform: "uppercase",
+ letterSpacing: "0.2em",
+ padding: "6px 14px",
+ border: 0,
+ background: active ? tokens.text : "transparent",
+ color: active ? tokens.bg : tokens.text,
+ cursor: "pointer",
+ fontWeight: 700,
+ ...(isFirst ? {} : { borderLeft: `1px solid ${tokens.text}` }),
+ }),
+
+ monitorList: {
+ listStyle: "none",
+ m: 0,
+ p: 0,
+ display: "flex",
+ flexDirection: "column",
+ },
+ card: {
+ background: "transparent",
+ border: 0,
+ borderBottom: `1px solid ${tokens.border}`,
+ mb: 0,
+ py: "20px",
+ "&:last-of-type": { borderBottom: 0 },
+ },
+ cardRow: {
+ display: "grid",
+ gridTemplateColumns: "1fr auto",
+ alignItems: "baseline",
+ gap: "20px",
+ },
+ cardLeft: { minWidth: 0 },
+ monitorName: {
+ fontSize: 20,
+ fontWeight: 700,
+ letterSpacing: "-0.015em",
+ color: tokens.text,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ },
+ monitorMeta: {
+ fontFamily: sansStack,
+ fontSize: 12,
+ color: tokens.textMuted,
+ mt: "6px",
+ display: "flex",
+ gap: "12px",
+ flexWrap: "wrap",
+ alignItems: "center",
+ },
+ pill: {
+ textTransform: "uppercase",
+ letterSpacing: "0.12em",
+ fontWeight: 700,
+ color: tokens.textMuted,
+ },
+ pillHardware: {
+ textTransform: "uppercase",
+ letterSpacing: "0.12em",
+ fontWeight: 700,
+ color: tokens.up,
+ },
+ monitorUrl: {
+ fontFamily: "ui-monospace, Menlo, monospace",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ maxWidth: 280,
+ fontSize: 12,
+ },
+
+ badge: (tone) => ({
+ fontFamily: sansStack,
+ fontSize: 10,
+ fontWeight: 700,
+ padding: "4px 10px",
+ borderRadius: 0,
+ textTransform: "uppercase",
+ letterSpacing: "0.15em",
+ whiteSpace: "nowrap",
+ background: toneSoft(tone, tokens),
+ color: toneColor(tone, tokens),
+ }),
+
+ heatmap: {
+ mt: "16px",
+ display: "grid",
+ gridTemplateColumns: "repeat(25, 1fr)",
+ gap: "2px",
+ height: 40,
+ },
+ heatmapCell: (kind) => ({
+ background: heatCellBg[kind],
+ opacity: kind === "empty" ? 0.5 : 1,
+ }),
+
+ histogram: {
+ mt: "16px",
+ display: "grid",
+ gridTemplateColumns: "repeat(25, 1fr)",
+ gap: "2px",
+ alignItems: "flex-end",
+ height: 40,
+ },
+ bar: (kind, heightPct) => ({
+ background: barBg[kind],
+ minHeight: 3,
+ opacity: kind === "empty" ? 0.5 : 1,
+ height: `${heightPct}%`,
+ }),
+ chartStats: {
+ fontFamily: sansStack,
+ fontSize: 11,
+ color: tokens.textMuted,
+ fontVariantNumeric: "tabular-nums",
+ textTransform: "uppercase",
+ letterSpacing: "0.1em",
+ },
+
+ infra: {
+ display: "grid",
+ gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
+ gap: "24px",
+ mt: "20px",
+ fontFamily: sansStack,
+ },
+ infraEmpty: { mt: "20px", color: tokens.textMuted, fontSize: 13 },
+ gauge: {},
+ gaugeLabel: {
+ fontSize: 10,
+ color: tokens.textMuted,
+ textTransform: "uppercase",
+ letterSpacing: "0.18em",
+ fontWeight: 700,
+ },
+ gaugeValue: {
+ fontSize: 28,
+ fontWeight: 700,
+ letterSpacing: "-0.02em",
+ mt: "4px",
+ fontVariantNumeric: "tabular-nums",
+ fontFamily: serifStack,
+ color: tokens.text,
+ },
+ gaugeBar: { height: 2, background: tokens.border, mt: "10px" },
+ gaugeFill: (level, widthPct) => ({
+ display: "block",
+ height: "100%",
+ background: gaugeFillBg[level],
+ transition: "width 0.8s",
+ width: `${Math.max(0, Math.min(100, widthPct))}%`,
+ }),
+ gaugeSub: {
+ fontSize: 11,
+ color: tokens.textMuted,
+ mt: "8px",
+ fontVariantNumeric: "tabular-nums",
+ },
+
+ footer: {
+ textAlign: "center",
+ color: tokens.textMuted,
+ fontSize: 11,
+ mt: "56px",
+ textTransform: "uppercase",
+ letterSpacing: "0.2em",
+ fontFamily: sansStack,
+ "& a": {
+ color: tokens.text,
+ textDecoration: "none",
+ borderBottom: `1px solid ${tokens.text}`,
+ pb: "2px",
+ fontWeight: 700,
+ },
+ },
+ };
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx
index f853d90d6..6d2046c0f 100644
--- a/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx
@@ -1,4 +1,4 @@
-import { useState } from "react";
+import { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { useTranslation } from "react-i18next";
@@ -14,7 +14,8 @@ import {
resolveOverallStatus,
statusBadgeKey,
} from "../shared/overallStatus";
-import "./modern.css";
+import { useStatusPageTheme } from "../StatusPageThemeProvider";
+import { modernStyles } from "./styles";
type StatusPageMonitor = Monitor & { checks?: Monitor["recentChecks"] };
@@ -23,10 +24,10 @@ interface Props {
monitors: StatusPageMonitor[];
}
-const PREFIX = "mod";
-
export const ModernStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
+ const { tokens, mode } = useStatusPageTheme();
+ const styles = useMemo(() => modernStyles(tokens, mode === "dark"), [tokens, mode]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const overall = resolveOverallStatus(monitors, t, { iconSize: 20 });
@@ -35,72 +36,85 @@ export const ModernStatusPage = ({ statusPage, monitors }: Props) => {
: null;
return (
-
+
-
+
{logoSrc ? (
) : (
-
- {monoFirstChar(statusPage.companyName)}
-
+ {monoFirstChar(statusPage.companyName)}
)}
- {statusPage.companyName}
+
+ {statusPage.companyName}
+
-
-
-
- {overall.message}
-
+
+
+
+
+ {overall.message}
+
+
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
-
+
- {overall.icon}
+ {overall.icon}
{statusPage.showCharts && (
-
+
- setChartMode("heatmap")}
+ sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
-
-
+ setChartMode("histogram")}
+ sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
-
+
)}
{monitors.map((monitor) => {
const isHardware = monitor.type === "hardware";
@@ -112,48 +126,65 @@ export const ModernStatusPage = ({ statusPage, monitors }: Props) => {
-
-
- {monitor.name}
-
-
+
+ {monitor.name}
+
+
{getMonitorTypeLabel(monitor.type, t)}
-
+
{monitor.url && (
-
{monitor.url}
-
+
)}
-
+
{t(statusBadgeKey[monitor.status])}
-
+
{showInfra && (
)}
{showChart &&
(chartMode === "heatmap" ? (
) : (
))}
@@ -163,7 +194,7 @@ export const ModernStatusPage = ({ statusPage, monitors }: Props) => {
{t("pages.statusPages.footer.poweredBy")}{" "}
button {
- border: 0;
- background: transparent;
- font: inherit;
- font-size: 11px;
- padding: 6px 16px;
- cursor: pointer;
- color: var(--sp-text-muted);
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- transition:
- background 0.2s,
- color 0.2s;
- border-radius: 999px;
-}
-[data-status-theme="modern"] .mod-chart-switch > button:hover {
- color: var(--sp-text);
-}
-[data-status-theme="modern"] .mod-chart-switch > button.mod-active {
- color: var(--sp-up);
- background: var(--sp-up-soft);
-}
-
-/* --- cards: 16px radius, strong shadow, staggered fade-in, hover lift --- */
-[data-status-theme="modern"] .mod-monitor-list {
- list-style: none;
- margin: 0;
- padding: 0;
- display: flex;
- flex-direction: column;
- gap: 14px;
-}
-[data-status-theme="modern"] .mod-card {
- background: var(--sp-surface);
- border: 1px solid var(--sp-border);
- border-radius: 16px;
- box-shadow:
- 0 2px 4px rgba(16, 24, 40, 0.04),
- 0 12px 32px rgba(16, 24, 40, 0.06);
- overflow: hidden;
- transition:
- transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1),
- box-shadow 0.2s;
- opacity: 0;
- transform: translateY(8px);
- animation: mod-fadein 0.5s forwards;
-}
-[data-status-theme="modern"][data-status-mode="dark"] .mod-card {
- box-shadow:
- 0 2px 6px rgba(0, 0, 0, 0.5),
- 0 20px 40px rgba(0, 0, 0, 0.4);
-}
-[data-status-theme="modern"] .mod-card:nth-child(1) {
- animation-delay: 0.05s;
-}
-[data-status-theme="modern"] .mod-card:nth-child(2) {
- animation-delay: 0.1s;
-}
-[data-status-theme="modern"] .mod-card:nth-child(3) {
- animation-delay: 0.15s;
-}
-[data-status-theme="modern"] .mod-card:nth-child(4) {
- animation-delay: 0.2s;
-}
-[data-status-theme="modern"] .mod-card:nth-child(5) {
- animation-delay: 0.25s;
-}
-[data-status-theme="modern"] .mod-card:nth-child(n + 6) {
- animation-delay: 0.3s;
-}
-@keyframes mod-fadein {
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-[data-status-theme="modern"] .mod-card:hover {
- transform: translateY(-2px);
- box-shadow:
- 0 4px 8px rgba(16, 24, 40, 0.05),
- 0 16px 40px rgba(16, 24, 40, 0.08);
-}
-
-[data-status-theme="modern"] .mod-card-row {
- display: grid;
- grid-template-columns: 1fr auto;
- align-items: center;
- gap: 20px;
- padding: 18px 24px;
-}
-[data-status-theme="modern"] .mod-card-left {
- min-width: 0;
-}
-[data-status-theme="modern"] .mod-monitor-name {
- font-weight: 600;
- font-size: 15px;
- letter-spacing: -0.005em;
- color: var(--sp-text);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-[data-status-theme="modern"] .mod-monitor-meta {
- display: flex;
- gap: 10px;
- align-items: center;
- margin-top: 6px;
- flex-wrap: wrap;
-}
-[data-status-theme="modern"] .mod-pill {
- font-size: 10px;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--sp-text-muted);
- background: var(--sp-bg);
- padding: 3px 9px;
- border-radius: 999px;
- font-weight: 600;
-}
-[data-status-theme="modern"] .mod-pill-hardware {
- color: #2563eb;
- background: rgba(37, 99, 235, 0.1);
-}
-[data-status-theme="modern"][data-status-mode="dark"] .mod-pill-hardware {
- color: #60a5fa;
- background: rgba(96, 165, 250, 0.15);
-}
-[data-status-theme="modern"] .mod-monitor-url {
- font-size: 12px;
- color: var(--sp-text-muted);
- font-family: ui-monospace, Menlo, monospace;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 280px;
-}
-
-/* --- badge: with inner dot --- */
-[data-status-theme="modern"] .mod-badge {
- font-size: 11px;
- font-weight: 700;
- padding: 5px 12px;
- border-radius: 999px;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- white-space: nowrap;
-}
-[data-status-theme="modern"] .mod-badge::before {
- content: "";
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: currentColor;
-}
-[data-status-theme="modern"] .mod-badge-up {
- background: var(--sp-up-soft);
- color: var(--sp-up);
-}
-[data-status-theme="modern"] .mod-badge-warn {
- background: var(--sp-warn-soft);
- color: var(--sp-warn);
-}
-[data-status-theme="modern"] .mod-badge-down {
- background: var(--sp-down-soft);
- color: var(--sp-down);
-}
-
-/* --- heatmap with gradient cells --- */
-[data-status-theme="modern"] .mod-heatmap {
- padding: 0 24px 20px;
- display: grid;
- grid-template-columns: repeat(25, 1fr);
- gap: 3px;
- height: 46px;
-}
-[data-status-theme="modern"] .mod-heatmap-cell {
- border-radius: 3px;
- background: var(--sp-up);
- transition: transform 0.15s;
-}
-[data-status-theme="modern"] .mod-heatmap-cell:hover {
- transform: scaleY(1.2);
-}
-[data-status-theme="modern"] .mod-heatmap-cell.mod-fast {
- background: linear-gradient(180deg, var(--sp-up), var(--sp-up-strong));
-}
-[data-status-theme="modern"] .mod-heatmap-cell.mod-med {
- background: linear-gradient(
- 180deg,
- color-mix(in srgb, var(--sp-up) 70%, #ffffff 30%),
- var(--sp-up)
- );
-}
-[data-status-theme="modern"] .mod-heatmap-cell.mod-slow {
- background: linear-gradient(180deg, var(--sp-warn), var(--sp-degraded));
-}
-[data-status-theme="modern"] .mod-heatmap-cell.mod-down {
- background: linear-gradient(
- 180deg,
- var(--sp-down),
- color-mix(in srgb, var(--sp-down) 70%, #000000 30%)
- );
-}
-[data-status-theme="modern"] .mod-heatmap-cell.mod-empty {
- background: var(--sp-border);
- opacity: 0.4;
-}
-
-/* --- histogram with gradient bars --- */
-[data-status-theme="modern"] .mod-histogram {
- padding: 0 24px;
- display: grid;
- grid-template-columns: repeat(25, 1fr);
- gap: 3px;
- align-items: flex-end;
- height: 46px;
-}
-[data-status-theme="modern"] .mod-bar {
- background: var(--sp-up);
- border-radius: 3px;
- min-height: 4px;
- transition: transform 0.15s;
-}
-[data-status-theme="modern"] .mod-bar:hover {
- transform: scaleY(1.08);
-}
-[data-status-theme="modern"] .mod-bar.mod-down {
- background: var(--sp-down);
-}
-[data-status-theme="modern"] .mod-bar.mod-empty {
- background: var(--sp-border);
- opacity: 0.4;
-}
-[data-status-theme="modern"] .mod-chart-stats {
- padding: 0 24px 20px;
- font-size: 11px;
- color: var(--sp-text-muted);
- font-variant-numeric: tabular-nums;
- font-weight: 600;
-}
-
-/* --- infra gauges: 3-col (4 if temp present), gradient surfaces --- */
-[data-status-theme="modern"] .mod-infra {
- padding: 16px 24px 22px;
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 14px;
-}
-@media (max-width: 720px) {
- [data-status-theme="modern"] .mod-infra {
- grid-template-columns: 1fr;
- }
-}
-[data-status-theme="modern"] .mod-infra-empty {
- padding: 16px 24px 22px;
- color: var(--sp-text-muted);
- font-size: 13px;
-}
-[data-status-theme="modern"] .mod-gauge {
- border: 1px solid var(--sp-border);
- border-radius: 14px;
- padding: 14px 16px;
- background: linear-gradient(135deg, var(--sp-bg), var(--sp-surface));
-}
-[data-status-theme="modern"] .mod-gauge-label {
- font-size: 10px;
- color: var(--sp-text-muted);
- text-transform: uppercase;
- letter-spacing: 0.1em;
- font-weight: 700;
-}
-[data-status-theme="modern"] .mod-gauge-value {
- font-size: 26px;
- font-weight: 700;
- letter-spacing: -0.02em;
- margin-top: 4px;
- font-variant-numeric: tabular-nums;
- color: var(--sp-text);
-}
-[data-status-theme="modern"] .mod-gauge-bar {
- height: 6px;
- background: var(--sp-border);
- border-radius: 3px;
- overflow: hidden;
- margin-top: 10px;
-}
-[data-status-theme="modern"] .mod-gauge-fill {
- display: block;
- height: 100%;
- background: linear-gradient(90deg, var(--sp-up), var(--sp-up-strong));
- border-radius: 3px;
- transition: width 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
-}
-[data-status-theme="modern"] .mod-gauge-fill.mod-warm {
- background: linear-gradient(90deg, var(--sp-warn), var(--sp-degraded));
-}
-[data-status-theme="modern"] .mod-gauge-fill.mod-hot {
- background: linear-gradient(
- 90deg,
- var(--sp-down),
- color-mix(in srgb, var(--sp-down) 70%, #000000 30%)
- );
-}
-[data-status-theme="modern"] .mod-gauge-sub {
- font-size: 11px;
- color: var(--sp-text-muted);
- margin-top: 8px;
- font-variant-numeric: tabular-nums;
-}
-
-/* --- footer --- */
-[data-status-theme="modern"] .mod-footer {
- text-align: center;
- color: var(--sp-text-muted);
- font-size: 12px;
- margin-top: 48px;
-}
-[data-status-theme="modern"] .mod-footer a {
- color: var(--sp-up);
- text-decoration: underline;
- text-underline-offset: 3px;
- font-weight: 700;
-}
-[data-status-theme="modern"] .mod-footer a:hover {
- color: var(--sp-up-strong);
-}
-
-/* Reduced motion */
-@media (prefers-reduced-motion: reduce) {
- [data-status-theme="modern"] .mod-card {
- animation: none;
- opacity: 1;
- transform: none;
- }
- [data-status-theme="modern"] .mod-pulse::before,
- [data-status-theme="modern"] .mod-pulse::after {
- animation: none;
- }
-}
diff --git a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts
new file mode 100644
index 000000000..915b26b6a
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts
@@ -0,0 +1,431 @@
+import type { SxProps, Theme } from "@mui/material/styles";
+import { keyframes } from "@mui/system";
+import type { StatusPageThemeTokens } from "../tokens";
+import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
+
+export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty";
+export type ModernBarKind = "up" | "down" | "empty";
+export type ModernGaugeFill = "ok" | "warm" | "hot";
+
+export interface ModernStyles {
+ page: SxProps;
+ top: SxProps;
+ brand: SxProps;
+ logoGrad: SxProps;
+ logoImg: SxProps;
+ company: SxProps;
+ hero: SxProps;
+ pulse: (tone: OverallTone) => SxProps;
+ statusCopy: SxProps;
+ heroTitle: SxProps;
+ heroSub: SxProps;
+ heroIcon: (tone: OverallTone) => SxProps;
+ chartSwitchWrap: SxProps;
+ chartSwitch: SxProps;
+ chartSwitchButton: (active: boolean) => SxProps;
+ monitorList: SxProps;
+ card: SxProps;
+ cardRow: SxProps;
+ cardLeft: SxProps;
+ monitorName: SxProps;
+ monitorMeta: SxProps;
+ pill: SxProps;
+ pillHardware: SxProps;
+ monitorUrl: SxProps;
+ badge: (tone: OverallTone) => SxProps;
+ heatmap: SxProps;
+ heatmapCell: (kind: ModernHeatCell) => SxProps;
+ histogram: SxProps;
+ bar: (kind: ModernBarKind, heightPct: number) => SxProps;
+ chartStats: SxProps;
+ infra: SxProps;
+ infraEmpty: SxProps;
+ gauge: SxProps;
+ gaugeLabel: SxProps;
+ gaugeValue: SxProps;
+ gaugeBar: SxProps;
+ gaugeFill: (level: ModernGaugeFill, widthPct: number) => SxProps;
+ gaugeSub: SxProps;
+ footer: SxProps;
+}
+
+const sansFontStack =
+ '-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
+
+const lightShadow =
+ "0 2px 4px rgba(16, 24, 40, 0.04), 0 12px 32px rgba(16, 24, 40, 0.06)";
+const darkShadow = "0 2px 6px rgba(0, 0, 0, 0.5), 0 20px 40px rgba(0, 0, 0, 0.4)";
+const lightShadowHover =
+ "0 4px 8px rgba(16, 24, 40, 0.05), 0 16px 40px rgba(16, 24, 40, 0.08)";
+
+const pulseKeyframes = keyframes`
+ 0% { transform: scale(1); opacity: 0.5; }
+ 100% { transform: scale(2.4); opacity: 0; }
+`;
+
+const fadeInKeyframes = keyframes`
+ to { opacity: 1; transform: translateY(0); }
+`;
+
+export const modernStyles = (
+ tokens: StatusPageThemeTokens,
+ isDark: boolean
+): ModernStyles => {
+ const cardShadow = isDark ? darkShadow : lightShadow;
+
+ const heatCellBg: Record = {
+ fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`,
+ med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`,
+ slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`,
+ down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
+ empty: tokens.border,
+ };
+
+ const barBg: Record = {
+ up: tokens.up,
+ down: tokens.down,
+ empty: tokens.border,
+ };
+
+ const gaugeFillBg: Record = {
+ ok: `linear-gradient(90deg, ${tokens.up}, ${tokens.upStrong})`,
+ warm: `linear-gradient(90deg, ${tokens.warn}, ${tokens.degraded})`,
+ hot: `linear-gradient(90deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`,
+ };
+
+ return {
+ page: {
+ flex: "1 0 auto",
+ maxWidth: 980,
+ width: "100%",
+ mx: "auto",
+ p: "56px 20px 80px",
+ fontFamily: sansFontStack,
+ fontSize: 14,
+ lineHeight: 1.5,
+ color: tokens.text,
+ WebkitFontSmoothing: "antialiased",
+ },
+
+ top: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ mb: "32px",
+ },
+ brand: {
+ display: "flex",
+ alignItems: "center",
+ gap: "12px",
+ fontWeight: 700,
+ fontSize: 15,
+ letterSpacing: "-0.01em",
+ color: tokens.text,
+ },
+ logoGrad: {
+ width: 32,
+ height: 32,
+ borderRadius: "10px",
+ background: `linear-gradient(135deg, ${tokens.up}, ${tokens.upStrong})`,
+ display: "grid",
+ placeItems: "center",
+ color: "#fff",
+ fontWeight: 700,
+ fontSize: 14,
+ boxShadow: `0 4px 10px color-mix(in srgb, ${tokens.up} 40%, transparent)`,
+ },
+ logoImg: { maxHeight: 32, maxWidth: 120, objectFit: "contain" },
+ company: {},
+
+ hero: {
+ position: "relative",
+ borderRadius: "20px",
+ padding: "28px 32px",
+ mb: "24px",
+ display: "flex",
+ alignItems: "center",
+ gap: "20px",
+ background: tokens.surface,
+ border: `1px solid ${tokens.border}`,
+ boxShadow: cardShadow,
+ },
+ pulse: (tone) => {
+ const c = toneColor(tone, tokens);
+ return {
+ position: "relative",
+ width: 14,
+ height: 14,
+ borderRadius: "50%",
+ background: c,
+ color: c,
+ flexShrink: 0,
+ "&::before, &::after": {
+ content: '""',
+ position: "absolute",
+ inset: 0,
+ borderRadius: "50%",
+ background: "currentColor",
+ opacity: 0.5,
+ animation: `${pulseKeyframes} 2s ease-out infinite`,
+ },
+ "&::after": { animationDelay: "1s" },
+ "@media (prefers-reduced-motion: reduce)": {
+ "&::before, &::after": { animation: "none" },
+ },
+ };
+ },
+ statusCopy: { flex: 1, minWidth: 0 },
+ heroTitle: {
+ m: 0,
+ mb: "4px",
+ fontSize: 22,
+ fontWeight: 700,
+ letterSpacing: "-0.02em",
+ color: tokens.text,
+ },
+ heroSub: { m: 0, color: tokens.textMuted, fontSize: 13 },
+ heroIcon: (tone) => ({
+ color: toneColor(tone, tokens),
+ display: "flex",
+ alignItems: "center",
+ }),
+
+ chartSwitchWrap: { display: "flex", justifyContent: "flex-end", mb: "14px" },
+ chartSwitch: {
+ display: "inline-flex",
+ border: `1px solid ${tokens.border}`,
+ borderRadius: "999px",
+ background: tokens.surface,
+ p: "3px",
+ gap: "2px",
+ },
+ chartSwitchButton: (active) => ({
+ border: 0,
+ background: active ? tokens.upSoft : "transparent",
+ fontFamily: "inherit",
+ fontSize: 11,
+ padding: "6px 16px",
+ cursor: "pointer",
+ color: active ? tokens.up : tokens.textMuted,
+ fontWeight: 600,
+ textTransform: "uppercase",
+ letterSpacing: "0.08em",
+ transition: "background 0.2s, color 0.2s",
+ borderRadius: "999px",
+ "&:hover": { color: active ? tokens.up : tokens.text },
+ }),
+
+ monitorList: {
+ listStyle: "none",
+ m: 0,
+ p: 0,
+ display: "flex",
+ flexDirection: "column",
+ gap: "14px",
+ },
+ card: {
+ background: tokens.surface,
+ border: `1px solid ${tokens.border}`,
+ borderRadius: "16px",
+ boxShadow: cardShadow,
+ overflow: "hidden",
+ transition: "transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.2s",
+ opacity: 0,
+ transform: "translateY(8px)",
+ animation: `${fadeInKeyframes} 0.5s forwards`,
+ "&:nth-of-type(1)": { animationDelay: "0.05s" },
+ "&:nth-of-type(2)": { animationDelay: "0.1s" },
+ "&:nth-of-type(3)": { animationDelay: "0.15s" },
+ "&:nth-of-type(4)": { animationDelay: "0.2s" },
+ "&:nth-of-type(5)": { animationDelay: "0.25s" },
+ "&:nth-of-type(n+6)": { animationDelay: "0.3s" },
+ "&:hover": { transform: "translateY(-2px)", boxShadow: lightShadowHover },
+ "@media (prefers-reduced-motion: reduce)": {
+ animation: "none",
+ opacity: 1,
+ transform: "none",
+ },
+ },
+ cardRow: {
+ display: "grid",
+ gridTemplateColumns: "1fr auto",
+ alignItems: "center",
+ gap: "20px",
+ p: "18px 24px",
+ },
+ cardLeft: { minWidth: 0 },
+ monitorName: {
+ fontWeight: 600,
+ fontSize: 15,
+ letterSpacing: "-0.005em",
+ color: tokens.text,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ },
+ monitorMeta: {
+ display: "flex",
+ gap: "10px",
+ alignItems: "center",
+ mt: "6px",
+ flexWrap: "wrap",
+ },
+ pill: {
+ fontSize: 10,
+ textTransform: "uppercase",
+ letterSpacing: "0.08em",
+ color: tokens.textMuted,
+ background: tokens.bg,
+ padding: "3px 9px",
+ borderRadius: "999px",
+ fontWeight: 600,
+ },
+ pillHardware: {
+ fontSize: 10,
+ textTransform: "uppercase",
+ letterSpacing: "0.08em",
+ padding: "3px 9px",
+ borderRadius: "999px",
+ fontWeight: 600,
+ color: isDark ? "#60a5fa" : "#2563eb",
+ background: isDark ? "rgba(96, 165, 250, 0.15)" : "rgba(37, 99, 235, 0.1)",
+ },
+ monitorUrl: {
+ fontSize: 12,
+ color: tokens.textMuted,
+ fontFamily: "ui-monospace, Menlo, monospace",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ maxWidth: 280,
+ },
+
+ badge: (tone) => ({
+ fontSize: 11,
+ fontWeight: 700,
+ padding: "5px 12px",
+ borderRadius: "999px",
+ display: "inline-flex",
+ alignItems: "center",
+ gap: "6px",
+ whiteSpace: "nowrap",
+ background: toneSoft(tone, tokens),
+ color: toneColor(tone, tokens),
+ "&::before": {
+ content: '""',
+ width: 6,
+ height: 6,
+ borderRadius: "50%",
+ background: "currentColor",
+ },
+ }),
+
+ heatmap: {
+ padding: "0 24px 20px",
+ display: "grid",
+ gridTemplateColumns: "repeat(25, 1fr)",
+ gap: "3px",
+ height: 46,
+ },
+ heatmapCell: (kind) => ({
+ borderRadius: "3px",
+ background: heatCellBg[kind],
+ opacity: kind === "empty" ? 0.4 : 1,
+ transition: "transform 0.15s",
+ "&:hover": { transform: "scaleY(1.2)" },
+ }),
+
+ histogram: {
+ padding: "0 24px",
+ display: "grid",
+ gridTemplateColumns: "repeat(25, 1fr)",
+ gap: "3px",
+ alignItems: "flex-end",
+ height: 46,
+ },
+ bar: (kind, heightPct) => ({
+ background: barBg[kind],
+ borderRadius: "3px",
+ minHeight: 4,
+ opacity: kind === "empty" ? 0.4 : 1,
+ height: `${heightPct}%`,
+ transition: "transform 0.15s",
+ "&:hover": { transform: "scaleY(1.08)" },
+ }),
+ chartStats: {
+ padding: "0 24px 20px",
+ fontSize: 11,
+ color: tokens.textMuted,
+ fontVariantNumeric: "tabular-nums",
+ fontWeight: 600,
+ },
+
+ infra: {
+ padding: "16px 24px 22px",
+ display: "grid",
+ gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
+ gap: "14px",
+ },
+ infraEmpty: {
+ padding: "16px 24px 22px",
+ color: tokens.textMuted,
+ fontSize: 13,
+ },
+ gauge: {
+ border: `1px solid ${tokens.border}`,
+ borderRadius: "14px",
+ p: "14px 16px",
+ background: `linear-gradient(135deg, ${tokens.bg}, ${tokens.surface})`,
+ },
+ gaugeLabel: {
+ fontSize: 10,
+ color: tokens.textMuted,
+ textTransform: "uppercase",
+ letterSpacing: "0.1em",
+ fontWeight: 700,
+ },
+ gaugeValue: {
+ fontSize: 26,
+ fontWeight: 700,
+ letterSpacing: "-0.02em",
+ mt: "4px",
+ fontVariantNumeric: "tabular-nums",
+ color: tokens.text,
+ },
+ gaugeBar: {
+ height: 6,
+ background: tokens.border,
+ borderRadius: "3px",
+ overflow: "hidden",
+ mt: "10px",
+ },
+ gaugeFill: (level, widthPct) => ({
+ display: "block",
+ height: "100%",
+ background: gaugeFillBg[level],
+ borderRadius: "3px",
+ transition: "width 0.8s cubic-bezier(0.2, 0.8, 0.2, 1)",
+ width: `${Math.max(0, Math.min(100, widthPct))}%`,
+ }),
+ gaugeSub: {
+ fontSize: 11,
+ color: tokens.textMuted,
+ mt: "8px",
+ fontVariantNumeric: "tabular-nums",
+ },
+
+ footer: {
+ textAlign: "center",
+ color: tokens.textMuted,
+ fontSize: 12,
+ mt: "48px",
+ "& a": {
+ color: tokens.up,
+ textDecoration: "underline",
+ textUnderlineOffset: "3px",
+ fontWeight: 700,
+ "&:hover": { color: tokens.upStrong },
+ },
+ },
+ };
+};
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
index ebd275ddc..30510611b 100644
--- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
@@ -13,11 +13,8 @@ export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty";
interface Props {
checks: CheckSnapshot[];
- /** sx-based API — preferred. Themes pass their own container + cell recipes. */
- containerSx?: SxProps;
- cellSx?: (kind: HeatCellKind) => SxProps;
- /** Legacy className-based API — kept for unmigrated themes. Will be removed once all themes move to sx. */
- classPrefix?: string;
+ containerSx: SxProps;
+ cellSx: (kind: HeatCellKind) => SxProps;
}
const classify = (check: CheckSnapshot): Exclude => {
@@ -28,9 +25,8 @@ const classify = (check: CheckSnapshot): Exclude => {
return "fast";
};
-export const ThemedHeatmap = ({ checks, containerSx, cellSx, classPrefix }: Props) => {
+export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
const { t } = useTranslation();
- const useSxApi = Boolean(containerSx && cellSx);
const source = checks.slice(0, CELLS).reverse();
const padded: (CheckSnapshot | null)[] = [
@@ -38,18 +34,9 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx, classPrefix }: Prop
...source,
];
- const containerProps = useSxApi
- ? { sx: containerSx }
- : { className: `${classPrefix}-heatmap` };
-
- const cellProps = (kind: HeatCellKind) =>
- useSxApi
- ? { sx: cellSx!(kind) }
- : { className: `${classPrefix}-heatmap-cell ${classPrefix}-${kind}` };
-
return (
@@ -58,7 +45,7 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx, classPrefix }: Prop
return (
);
}
@@ -89,7 +76,7 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx, classPrefix }: Prop
placement="top"
>
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
index af73095df..8af4ffebf 100644
--- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
@@ -15,12 +15,9 @@ export type BarKind = "up" | "down" | "empty";
interface Props {
checks: CheckSnapshot[];
- /** sx-based API — preferred. */
- containerSx?: SxProps;
- barSx?: (kind: BarKind, heightPct: number) => SxProps;
- statsSx?: SxProps;
- /** Legacy className-based API. Will be removed once all themes move to sx. */
- classPrefix?: string;
+ containerSx: SxProps;
+ barSx: (kind: BarKind, heightPct: number) => SxProps;
+ statsSx: SxProps;
statsGap?: number;
}
@@ -32,11 +29,9 @@ export const ThemedHistogram = ({
containerSx,
barSx,
statsSx,
- classPrefix,
statsGap = 1,
}: Props) => {
const { t } = useTranslation();
- const useSxApi = Boolean(containerSx && barSx && statsSx);
const { padded, max, avg, peak } = useMemo(() => {
const source = checks.slice(0, CELLS).reverse();
@@ -52,29 +47,15 @@ export const ThemedHistogram = ({
return { padded: out, max: maxRt, avg: avgRt, peak: valid.length ? maxRt : 0 };
}, [checks]);
- const containerProps = useSxApi
- ? { sx: containerSx }
- : { className: `${classPrefix}-histogram` };
-
- const statsProps = useSxApi
- ? { sx: statsSx }
- : { className: `${classPrefix}-chart-stats` };
-
return (
-
+
{padded.map((check, i) => {
if (!check) {
- return useSxApi ? (
+ return (
- ) : (
-
);
}
@@ -107,18 +88,10 @@ export const ThemedHistogram = ({
arrow
placement="top"
>
- {useSxApi ? (
-
- ) : (
-
- )}
+
);
})}
@@ -126,7 +99,7 @@ export const ThemedHistogram = ({
{t("pages.statusPages.monitorsList.chart.avg", { value: avg })}
{t("pages.statusPages.monitorsList.chart.max", { value: peak })}
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx
index 8c642a344..01804a322 100644
--- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedInfrastructure.tsx
@@ -6,7 +6,7 @@ import type { Monitor } from "@/Types/Monitor";
export type GaugeFillLevel = "ok" | "warm" | "hot";
-interface InfraSx {
+export interface InfraSx {
containerSx: SxProps;
emptySx: SxProps;
gaugeSx: SxProps;
@@ -19,10 +19,7 @@ interface InfraSx {
interface Props {
monitor: Monitor & { checks?: Monitor["recentChecks"] };
- /** sx-based API — preferred. */
- sxApi?: InfraSx;
- /** Legacy className-based API. Will be removed once all themes move to sx. */
- classPrefix?: string;
+ sxApi: InfraSx;
}
interface Gauge {
@@ -37,19 +34,13 @@ const PCT = 100;
const heatLevel = (value: number): GaugeFillLevel =>
value > 85 ? "hot" : value > 70 ? "warm" : "ok";
-export const ThemedInfrastructure = ({ monitor, sxApi, classPrefix }: Props) => {
+export const ThemedInfrastructure = ({ monitor, sxApi }: Props) => {
const { t } = useTranslation();
- const useSxApi = Boolean(sxApi);
const latest = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
- const renderEmpty = () => {
- const message = t("pages.statusPages.monitorsList.noData");
- return useSxApi ? (
- {message}
- ) : (
- {message}
- );
- };
+ const renderEmpty = () => (
+ {t("pages.statusPages.monitorsList.noData")}
+ );
if (!latest) return renderEmpty();
@@ -94,49 +85,21 @@ export const ThemedInfrastructure = ({ monitor, sxApi, classPrefix }: Props) =>
if (gauges.length === 0) return renderEmpty();
- if (useSxApi) {
- const s = sxApi!;
- return (
-
- {gauges.map((g) => (
-
- {g.label}
- {g.value.toFixed(0)}%
-
-
-
- {g.sub}
-
- ))}
-
- );
- }
-
return (
-
- {gauges.map((g) => {
- const level = heatLevel(g.value);
- const heatClass = level === "ok" ? "" : `${classPrefix}-${level}`;
- return (
-
- {g.label}
- {g.value.toFixed(0)}%
-
-
-
- {g.sub}
+
+ {gauges.map((g) => (
+
+ {g.label}
+ {g.value.toFixed(0)}%
+
+
- );
- })}
+ {g.sub}
+
+ ))}
);
};
From 4f8f2f14229e2ba6dd8c9f4d82c0dd7e9d8d56dd Mon Sep 17 00:00:00 2001
From: gorkem-bwl
Date: Mon, 27 Apr 2026 18:45:45 -0400
Subject: [PATCH 05/19] refactor(status-page): tighten the
bold/modern/editorial conversion
Apply review feedback:
- Editorial chartSwitch: drop the per-button isFirst flag and put the divider on the parent via "& > button + button { borderLeft }"; chartSwitchButton(active) now matches bold/modern's signature
- Drop the empty company: {} entries in bold/modern and the wrapper span around the company name; the parent brand row already styles it
- Editorial styles factory: keep the (tokens, isDark) signature for cross-theme uniformity but acknowledge the unused isDark with a void-statement instead of an underscore prefix
---
.../Status/themes/bold/BoldStatusPage.tsx | 7 +------
.../Pages/StatusPage/Status/themes/bold/styles.ts | 2 --
.../themes/editorial/EditorialStatusPage.tsx | 4 ++--
.../StatusPage/Status/themes/editorial/styles.ts | 14 +++++++++-----
.../Status/themes/modern/ModernStatusPage.tsx | 7 +------
.../StatusPage/Status/themes/modern/styles.ts | 2 --
6 files changed, 13 insertions(+), 23 deletions(-)
diff --git a/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx
index feb17d917..32959a2ed 100644
--- a/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/bold/BoldStatusPage.tsx
@@ -52,12 +52,7 @@ export const BoldStatusPage = ({ statusPage, monitors }: Props) => {
) : (
{monoFirstChar(statusPage.companyName)}
)}
-
- {statusPage.companyName}
-
+ {statusPage.companyName}
diff --git a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts
index 50f8c9cf0..39c9864a8 100644
--- a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts
+++ b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts
@@ -12,7 +12,6 @@ export interface BoldStyles {
brand: SxProps;
logoConic: SxProps;
logoImg: SxProps;
- company: SxProps;
hero: SxProps;
heroTitle: SxProps;
heroCheck: (tone: OverallTone) => SxProps;
@@ -128,7 +127,6 @@ export const boldStyles = (
boxShadow: `0 8px 24px color-mix(in srgb, ${tokens.up} 30%, transparent), inset 0 1px 0 rgba(255, 255, 255, 0.2)`,
},
logoImg: { maxHeight: 36, maxWidth: 140, objectFit: "contain" },
- company: {},
hero: {
position: "relative",
diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx
index e6c57a5bf..7b5ed16a5 100644
--- a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx
@@ -108,7 +108,7 @@ export const EditorialStatusPage = ({ statusPage, monitors }: Props) => {
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
- sx={styles.chartSwitchButton(chartMode === "heatmap", true)}
+ sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
@@ -118,7 +118,7 @@ export const EditorialStatusPage = ({ statusPage, monitors }: Props) => {
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
- sx={styles.chartSwitchButton(chartMode === "histogram", false)}
+ sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts
index c3948577e..eea8a88ff 100644
--- a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts
+++ b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts
@@ -18,7 +18,7 @@ export interface EditorialStyles {
dateline: SxProps;
chartSwitchWrap: SxProps;
chartSwitch: SxProps;
- chartSwitchButton: (active: boolean, isFirst: boolean) => SxProps;
+ chartSwitchButton: (active: boolean) => SxProps;
monitorList: SxProps;
card: SxProps;
cardRow: SxProps;
@@ -50,8 +50,9 @@ const sansStack = '-apple-system, "Helvetica Neue", Arial, sans-serif';
export const editorialStyles = (
tokens: StatusPageThemeTokens,
- _isDark: boolean
+ isDark: boolean
): EditorialStyles => {
+ void isDark;
const heatCellBg: Record = {
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
@@ -150,8 +151,12 @@ export const editorialStyles = (
mb: "20px",
fontFamily: sansStack,
},
- chartSwitch: { display: "inline-flex", border: `1px solid ${tokens.text}` },
- chartSwitchButton: (active, isFirst) => ({
+ chartSwitch: {
+ display: "inline-flex",
+ border: `1px solid ${tokens.text}`,
+ "& > button + button": { borderLeft: `1px solid ${tokens.text}` },
+ },
+ chartSwitchButton: (active) => ({
fontFamily: "inherit",
fontSize: 10,
textTransform: "uppercase",
@@ -162,7 +167,6 @@ export const editorialStyles = (
color: active ? tokens.bg : tokens.text,
cursor: "pointer",
fontWeight: 700,
- ...(isFirst ? {} : { borderLeft: `1px solid ${tokens.text}` }),
}),
monitorList: {
diff --git a/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx
index 6d2046c0f..461c5e0f5 100644
--- a/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/modern/ModernStatusPage.tsx
@@ -52,12 +52,7 @@ export const ModernStatusPage = ({ statusPage, monitors }: Props) => {
) : (
{monoFirstChar(statusPage.companyName)}
)}
-
- {statusPage.companyName}
-
+ {statusPage.companyName}
diff --git a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts
index 915b26b6a..e9fe9e0d3 100644
--- a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts
+++ b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts
@@ -13,7 +13,6 @@ export interface ModernStyles {
brand: SxProps;
logoGrad: SxProps;
logoImg: SxProps;
- company: SxProps;
hero: SxProps;
pulse: (tone: OverallTone) => SxProps;
statusCopy: SxProps;
@@ -135,7 +134,6 @@ export const modernStyles = (
boxShadow: `0 4px 10px color-mix(in srgb, ${tokens.up} 40%, transparent)`,
},
logoImg: { maxHeight: 32, maxWidth: 120, objectFit: "contain" },
- company: {},
hero: {
position: "relative",
From ab9e722caa9872378c9078e5b6c610eaa4aeed9e Mon Sep 17 00:00:00 2001
From: gorkem-bwl
Date: Mon, 27 Apr 2026 18:52:53 -0400
Subject: [PATCH 06/19] refactor(status-page): consolidate font stacks and pill
recipes
Second /simplify pass picked up:
- pillHardware in refined and modern hardcoded blue hex codes (#60a5fa, #1d4ed8, #2563eb) while bold and editorial already used the green token system. Switch refined and modern to tokens.up / tokens.upSoft so all four themes are consistent and the public status page no longer leaks blue
- pill and pillHardware in refined and modern duplicated the shared 6 typography fields (fontSize, textTransform, letterSpacing, padding, borderRadius, fontWeight). Extract a pillBase constant per theme and spread, leaving only the colour deltas on each
- The 4 themes each defined their own font-stack constants. Consolidate them in themes/shared/fontStacks.ts (SANS_STACK, BOLD_SANS_STACK, SERIF_STACK, EDITORIAL_SECONDARY_SANS_STACK, MONO_STACK) and import. Drops 6 duplicated literal stacks
- modern dark-mode card hover used the light-mode shadow as a fallback. Add darkShadowHover and pick it up via cardHoverShadow when isDark
- refined and editorial styles factories silence their unused isDark parameter with `void isDark` instead of an underscore prefix; bold and modern still use it actively
---
.../StatusPage/Status/themes/bold/styles.ts | 8 ++--
.../Status/themes/editorial/styles.ts | 32 ++++++++-------
.../StatusPage/Status/themes/modern/styles.ts | 39 +++++++++---------
.../Status/themes/refined/styles.ts | 40 +++++++++----------
.../Status/themes/shared/fontStacks.ts | 11 +++++
5 files changed, 68 insertions(+), 62 deletions(-)
create mode 100644 client/src/Pages/StatusPage/Status/themes/shared/fontStacks.ts
diff --git a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts
index 39c9864a8..ac2eac9c0 100644
--- a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts
+++ b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts
@@ -1,6 +1,7 @@
import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
+import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks";
export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type BoldBarKind = "up" | "down" | "empty";
@@ -45,9 +46,6 @@ export interface BoldStyles {
footer: SxProps;
}
-const sansFontStack =
- 'ui-sans-serif, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
-
export const boldStyles = (
tokens: StatusPageThemeTokens,
isDark: boolean
@@ -92,7 +90,7 @@ export const boldStyles = (
width: "100%",
mx: "auto",
p: "56px 24px 96px",
- fontFamily: sansFontStack,
+ fontFamily: BOLD_SANS_STACK,
fontSize: 14,
lineHeight: 1.55,
color: tokens.text,
@@ -266,7 +264,7 @@ export const boldStyles = (
monitorUrl: {
fontSize: 12,
color: tokens.textMuted,
- fontFamily: "ui-monospace, Menlo, monospace",
+ fontFamily: MONO_STACK,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts
index eea8a88ff..233852875 100644
--- a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts
+++ b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts
@@ -1,6 +1,11 @@
import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
+import {
+ EDITORIAL_SECONDARY_SANS_STACK,
+ MONO_STACK,
+ SERIF_STACK,
+} from "../shared/fontStacks";
export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type EditorialBarKind = "up" | "down" | "empty";
@@ -45,9 +50,6 @@ export interface EditorialStyles {
footer: SxProps;
}
-const serifStack = 'Georgia, "Iowan Old Style", "Palatino Linotype", serif';
-const sansStack = '-apple-system, "Helvetica Neue", Arial, sans-serif';
-
export const editorialStyles = (
tokens: StatusPageThemeTokens,
isDark: boolean
@@ -80,7 +82,7 @@ export const editorialStyles = (
width: "100%",
mx: "auto",
p: "64px 24px 96px",
- fontFamily: serifStack,
+ fontFamily: SERIF_STACK,
fontSize: 15,
lineHeight: 1.6,
color: tokens.text,
@@ -104,7 +106,7 @@ export const editorialStyles = (
letterSpacing: "0.3em",
fontWeight: 700,
color: tokens.textMuted,
- fontFamily: sansStack,
+ fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
},
brandTitle: {
mt: "6px",
@@ -124,7 +126,7 @@ export const editorialStyles = (
letterSpacing: "-0.01em",
lineHeight: 1.35,
color: tokens.text,
- fontFamily: serifStack,
+ fontFamily: SERIF_STACK,
},
statusDot: (tone) => ({
display: "inline-block",
@@ -136,7 +138,7 @@ export const editorialStyles = (
verticalAlign: "middle",
}),
dateline: {
- fontFamily: sansStack,
+ fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 11,
textTransform: "uppercase",
letterSpacing: "0.12em",
@@ -149,7 +151,7 @@ export const editorialStyles = (
display: "flex",
justifyContent: "flex-end",
mb: "20px",
- fontFamily: sansStack,
+ fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
},
chartSwitch: {
display: "inline-flex",
@@ -201,7 +203,7 @@ export const editorialStyles = (
whiteSpace: "nowrap",
},
monitorMeta: {
- fontFamily: sansStack,
+ fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 12,
color: tokens.textMuted,
mt: "6px",
@@ -223,7 +225,7 @@ export const editorialStyles = (
color: tokens.up,
},
monitorUrl: {
- fontFamily: "ui-monospace, Menlo, monospace",
+ fontFamily: MONO_STACK,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
@@ -232,7 +234,7 @@ export const editorialStyles = (
},
badge: (tone) => ({
- fontFamily: sansStack,
+ fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 10,
fontWeight: 700,
padding: "4px 10px",
@@ -271,7 +273,7 @@ export const editorialStyles = (
height: `${heightPct}%`,
}),
chartStats: {
- fontFamily: sansStack,
+ fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
fontSize: 11,
color: tokens.textMuted,
fontVariantNumeric: "tabular-nums",
@@ -284,7 +286,7 @@ export const editorialStyles = (
gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
gap: "24px",
mt: "20px",
- fontFamily: sansStack,
+ fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
},
infraEmpty: { mt: "20px", color: tokens.textMuted, fontSize: 13 },
gauge: {},
@@ -301,7 +303,7 @@ export const editorialStyles = (
letterSpacing: "-0.02em",
mt: "4px",
fontVariantNumeric: "tabular-nums",
- fontFamily: serifStack,
+ fontFamily: SERIF_STACK,
color: tokens.text,
},
gaugeBar: { height: 2, background: tokens.border, mt: "10px" },
@@ -326,7 +328,7 @@ export const editorialStyles = (
mt: "56px",
textTransform: "uppercase",
letterSpacing: "0.2em",
- fontFamily: sansStack,
+ fontFamily: EDITORIAL_SECONDARY_SANS_STACK,
"& a": {
color: tokens.text,
textDecoration: "none",
diff --git a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts
index e9fe9e0d3..ad7356451 100644
--- a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts
+++ b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts
@@ -2,6 +2,7 @@ import type { SxProps, Theme } from "@mui/material/styles";
import { keyframes } from "@mui/system";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
+import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type ModernBarKind = "up" | "down" | "empty";
@@ -48,14 +49,21 @@ export interface ModernStyles {
footer: SxProps;
}
-const sansFontStack =
- '-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
-
const lightShadow =
"0 2px 4px rgba(16, 24, 40, 0.04), 0 12px 32px rgba(16, 24, 40, 0.06)";
const darkShadow = "0 2px 6px rgba(0, 0, 0, 0.5), 0 20px 40px rgba(0, 0, 0, 0.4)";
const lightShadowHover =
"0 4px 8px rgba(16, 24, 40, 0.05), 0 16px 40px rgba(16, 24, 40, 0.08)";
+const darkShadowHover = "0 4px 10px rgba(0, 0, 0, 0.55), 0 24px 48px rgba(0, 0, 0, 0.45)";
+
+const pillBase = {
+ fontSize: 10,
+ textTransform: "uppercase" as const,
+ letterSpacing: "0.08em",
+ padding: "3px 9px",
+ borderRadius: "999px",
+ fontWeight: 600,
+};
const pulseKeyframes = keyframes`
0% { transform: scale(1); opacity: 0.5; }
@@ -71,6 +79,7 @@ export const modernStyles = (
isDark: boolean
): ModernStyles => {
const cardShadow = isDark ? darkShadow : lightShadow;
+ const cardHoverShadow = isDark ? darkShadowHover : lightShadowHover;
const heatCellBg: Record = {
fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`,
@@ -99,7 +108,7 @@ export const modernStyles = (
width: "100%",
mx: "auto",
p: "56px 20px 80px",
- fontFamily: sansFontStack,
+ fontFamily: SANS_STACK,
fontSize: 14,
lineHeight: 1.5,
color: tokens.text,
@@ -237,7 +246,7 @@ export const modernStyles = (
"&:nth-of-type(4)": { animationDelay: "0.2s" },
"&:nth-of-type(5)": { animationDelay: "0.25s" },
"&:nth-of-type(n+6)": { animationDelay: "0.3s" },
- "&:hover": { transform: "translateY(-2px)", boxShadow: lightShadowHover },
+ "&:hover": { transform: "translateY(-2px)", boxShadow: cardHoverShadow },
"@media (prefers-reduced-motion: reduce)": {
animation: "none",
opacity: 1,
@@ -269,29 +278,19 @@ export const modernStyles = (
flexWrap: "wrap",
},
pill: {
- fontSize: 10,
- textTransform: "uppercase",
- letterSpacing: "0.08em",
+ ...pillBase,
color: tokens.textMuted,
background: tokens.bg,
- padding: "3px 9px",
- borderRadius: "999px",
- fontWeight: 600,
},
pillHardware: {
- fontSize: 10,
- textTransform: "uppercase",
- letterSpacing: "0.08em",
- padding: "3px 9px",
- borderRadius: "999px",
- fontWeight: 600,
- color: isDark ? "#60a5fa" : "#2563eb",
- background: isDark ? "rgba(96, 165, 250, 0.15)" : "rgba(37, 99, 235, 0.1)",
+ ...pillBase,
+ color: tokens.up,
+ background: tokens.upSoft,
},
monitorUrl: {
fontSize: 12,
color: tokens.textMuted,
- fontFamily: "ui-monospace, Menlo, monospace",
+ fontFamily: MONO_STACK,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
diff --git a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts
index 0789efd87..34d82a171 100644
--- a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts
+++ b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts
@@ -1,6 +1,7 @@
import type { SxProps, Theme } from "@mui/material/styles";
import type { StatusPageThemeTokens } from "../tokens";
import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus";
+import { MONO_STACK, SANS_STACK } from "../shared/fontStacks";
export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty";
export type RefinedBarKind = "up" | "down" | "empty";
@@ -48,17 +49,24 @@ export interface RefinedStyles {
footer: SxProps;
}
-const sansFontStack =
- '-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
-
const cardShadow = "0 1px 2px rgba(10, 16, 32, 0.06), 0 6px 18px rgba(10, 16, 32, 0.08)";
const cardShadowHover =
"0 2px 4px rgba(16, 24, 40, 0.06), 0 10px 24px rgba(16, 24, 40, 0.06)";
+const pillBase = {
+ fontSize: 10,
+ textTransform: "uppercase" as const,
+ letterSpacing: "0.08em",
+ padding: "2px 8px",
+ borderRadius: "999px",
+ fontWeight: 600,
+};
+
export const refinedStyles = (
tokens: StatusPageThemeTokens,
isDark: boolean
): RefinedStyles => {
+ void isDark;
const heatCellBg: Record = {
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
@@ -86,7 +94,7 @@ export const refinedStyles = (
width: "100%",
mx: "auto",
p: "48px 20px 80px",
- fontFamily: sansFontStack,
+ fontFamily: SANS_STACK,
fontSize: 14,
lineHeight: 1.5,
color: tokens.text,
@@ -222,32 +230,20 @@ export const refinedStyles = (
flexWrap: "wrap",
},
pill: {
- fontSize: 10,
- textTransform: "uppercase",
- letterSpacing: "0.08em",
+ ...pillBase,
color: tokens.textMuted,
border: `1px solid ${tokens.border}`,
- padding: "2px 8px",
- borderRadius: "999px",
- fontWeight: 600,
},
pillHardware: {
- fontSize: 10,
- textTransform: "uppercase",
- letterSpacing: "0.08em",
- padding: "2px 8px",
- borderRadius: "999px",
- fontWeight: 600,
- color: isDark ? "#60a5fa" : "#1d4ed8",
- border: `1px solid ${
- isDark ? "rgba(96, 165, 250, 0.3)" : "rgba(29, 78, 216, 0.3)"
- }`,
- background: isDark ? "rgba(96, 165, 250, 0.1)" : "rgba(29, 78, 216, 0.06)",
+ ...pillBase,
+ color: tokens.up,
+ border: `1px solid ${tokens.border}`,
+ background: tokens.upSoft,
},
monitorUrl: {
fontSize: 12,
color: tokens.textMuted,
- fontFamily: "ui-monospace, Menlo, monospace",
+ fontFamily: MONO_STACK,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/fontStacks.ts b/client/src/Pages/StatusPage/Status/themes/shared/fontStacks.ts
new file mode 100644
index 000000000..2ad63fd07
--- /dev/null
+++ b/client/src/Pages/StatusPage/Status/themes/shared/fontStacks.ts
@@ -0,0 +1,11 @@
+export const SANS_STACK =
+ '-apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif';
+
+export const BOLD_SANS_STACK = `ui-sans-serif, ${SANS_STACK}`;
+
+export const SERIF_STACK = 'Georgia, "Iowan Old Style", "Palatino Linotype", serif';
+
+export const EDITORIAL_SECONDARY_SANS_STACK =
+ '-apple-system, "Helvetica Neue", Arial, sans-serif';
+
+export const MONO_STACK = "ui-monospace, Menlo, monospace";
From ed3645c3d0ae63706b805769f7cc8c8a26402d32 Mon Sep 17 00:00:00 2001
From: gorkem-bwl
Date: Tue, 28 Apr 2026 16:38:35 -0400
Subject: [PATCH 07/19] refactor(status-page): drop legacy single-design
renderer and CSS overrides
The 4 themed renderers (refined, modern, bold, editorial) cover every
StatusPageTheme value and the model defaults to "refined", so the legacy
fallback path in Status/index.tsx was unreachable in practice.
Removing it lets us delete:
- theme-overrides.css (the only remaining CSS file in StatusPage)
- StatusBar / MonitorsList / InfrastructureMetrics (only the legacy
fallback consumed them)
- The .sp-* className hooks that wired CSS rules into the components
Pure sx via the per-theme styles.ts recipes is now the single source of
truth for every status page surface. Net diff is -801 / +42.
Status/index.tsx now resolves a renderer from the theme map (falling back
to the default theme) and keeps the public-route and admin-preview
branches it already had.
---
.../Components/HeaderStatusPageControls.tsx | 1 -
.../Components/InfrastructureMetrics.tsx | 246 ------------------
.../Status/Components/MonitorsList.tsx | 174 -------------
.../Status/Components/StatusBar.tsx | 173 ------------
client/src/Pages/StatusPage/Status/index.tsx | 134 +++-------
.../Status/themes/StatusPageThemeProvider.tsx | 1 -
.../Status/themes/theme-overrides.css | 156 -----------
7 files changed, 42 insertions(+), 843 deletions(-)
delete mode 100644 client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx
delete mode 100644 client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx
delete mode 100644 client/src/Pages/StatusPage/Status/Components/StatusBar.tsx
delete mode 100644 client/src/Pages/StatusPage/Status/themes/theme-overrides.css
diff --git a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx
index 1371fa722..2cc2395bb 100644
--- a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx
+++ b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx
@@ -37,7 +37,6 @@ export const HeaderStatusPageControls = ({
>
{
- const theme = useTheme();
- return (
-
-
- {label}
-
- {value}
-
- );
-};
-
-interface MetricItemProps {
- label: string;
- progress: number;
- details?: MetricDetail[];
-}
-
-const MetricItem = ({ label, progress, details }: MetricItemProps) => {
- const theme = useTheme();
- const isSmall = useMediaQuery(theme.breakpoints.down("md"));
- return (
-
-
-
- {label}
-
- {details && details.length > 0 && (
-
- {details.map((detail) => (
-
- ))}
-
- )}
-
- );
-};
-
-type LatestCheck = NonNullable[number];
-
-const buildCpuMetric = (
- check: LatestCheck,
- t: (key: string) => string
-): MetricConfig | null => {
- if (!check.cpu || typeof check.cpu.usage_percent !== "number") {
- return null;
- }
- const usagePercent = (check.cpu.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER;
- return {
- key: "cpu",
- label: t("pages.statusPages.monitorsList.infrastructure.cpu"),
- hasData: true,
- progress: usagePercent,
- details: [
- {
- label: t("pages.statusPages.monitorsList.infrastructure.usage"),
- value: `${usagePercent.toFixed(2)}%`,
- },
- ],
- };
-};
-
-const buildMemoryMetric = (
- check: LatestCheck,
- t: (key: string) => string
-): MetricConfig | null => {
- if (
- !check.memory ||
- typeof check.memory.usage_percent !== "number" ||
- typeof check.memory.used_bytes !== "number" ||
- typeof check.memory.total_bytes !== "number"
- ) {
- return null;
- }
- return {
- key: "memory",
- label: t("pages.statusPages.monitorsList.infrastructure.memory"),
- hasData: true,
- progress: (check.memory.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER,
- details: [
- {
- label: t("pages.statusPages.monitorsList.infrastructure.used"),
- value: prettyBytes(check.memory.used_bytes ?? 0),
- },
- {
- label: t("pages.statusPages.monitorsList.infrastructure.total"),
- value: prettyBytes(check.memory.total_bytes ?? 0),
- },
- ],
- };
-};
-
-const buildDiskMetric = (
- check: LatestCheck,
- t: (key: string) => string
-): MetricConfig | null => {
- if (!check.disk || check.disk.length === 0) {
- return null;
- }
- const disks = check.disk;
- const avgUsagePercent =
- (disks.reduce((acc, disk) => acc + (disk?.usage_percent ?? 0), 0) / disks.length) *
- PERCENTAGE_MULTIPLIER;
- const totalUsedBytes = disks.reduce((acc, disk) => acc + (disk?.used_bytes ?? 0), 0);
- const totalTotalBytes = disks.reduce((acc, disk) => acc + (disk?.total_bytes ?? 0), 0);
-
- return {
- key: "disk",
- label: t("pages.statusPages.monitorsList.infrastructure.disk"),
- hasData: true,
- progress: avgUsagePercent,
- details: [
- {
- label: t("pages.statusPages.monitorsList.infrastructure.used"),
- value: prettyBytes(totalUsedBytes),
- },
- {
- label: t("pages.statusPages.monitorsList.infrastructure.total"),
- value: prettyBytes(totalTotalBytes),
- },
- ],
- };
-};
-
-export const InfrastructureMetrics = ({ monitor }: { monitor: StatusPageMonitor }) => {
- const theme = useTheme();
- const { t } = useTranslation();
-
- const latestCheck = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
-
- if (!latestCheck) {
- return (
-
- {t("pages.statusPages.monitorsList.noData")}
-
- );
- }
-
- const metrics: MetricConfig[] = [
- buildCpuMetric(latestCheck, t),
- buildMemoryMetric(latestCheck, t),
- buildDiskMetric(latestCheck, t),
- ].filter((m): m is MetricConfig => m !== null);
-
- if (metrics.length === 0) {
- return (
-
- {t("pages.statusPages.monitorsList.noData")}
-
- );
- }
-
- return (
-
- {metrics.map(({ key, label, progress, details }) => (
-
- ))}
-
- );
-};
diff --git a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx
deleted file mode 100644
index 01e7d1975..000000000
--- a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx
+++ /dev/null
@@ -1,174 +0,0 @@
-import Stack from "@mui/material/Stack";
-import { useTranslation } from "react-i18next";
-import Box from "@mui/material/Box";
-import Typography from "@mui/material/Typography";
-import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common";
-import { StatusLabel, BaseBox } from "@/Components/design-elements";
-import { SwitchComponent } from "@/Components/inputs";
-import { InfrastructureMetrics } from "@/Pages/StatusPage/Status/Components/InfrastructureMetrics";
-
-import { useTheme, type Theme } from "@mui/material/styles";
-import { useSelector } from "react-redux";
-import { useState } from "react";
-import type { Monitor } from "@/Types/Monitor";
-import type { StatusPage } from "@/Types/StatusPage";
-import { getMonitorTypeLabel } from "@/Types/StatusPage";
-import type { RootState } from "@/Types/state";
-import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
-
-interface StatusPageMonitor extends Monitor {
- checks?: Monitor["recentChecks"];
-}
-
-interface MonitorsListProps {
- statusPage: StatusPage;
- monitors: StatusPageMonitor[];
-}
-
-const getMonitorBadgeStyles = (monitorType: string, theme: Theme) => {
- const bg =
- monitorType === "hardware" ? theme.palette.info.light : theme.palette.success.light;
- return {
- backgroundColor: bg,
- color: theme.palette.background.paper,
- padding: `${theme.spacing(SPACING.SM)} ${theme.spacing(SPACING.LG)}`,
- borderRadius: theme.shape.borderRadius,
- };
-};
-
-const MonitorHeader = ({
- monitor,
- showURL,
-}: {
- monitor: StatusPageMonitor;
- showURL: boolean;
-}) => {
- const theme = useTheme();
- const { t } = useTranslation();
-
- return (
-
-
-
-
- {monitor.name}
-
-
- {getMonitorTypeLabel(monitor.type, t)}
-
-
- {showURL && monitor.url && (
-
- {monitor.url}
-
- )}
-
-
-
- );
-};
-
-const MonitorContent = ({
- monitor,
- statusPage,
- chartType,
-}: {
- monitor: StatusPageMonitor;
- statusPage: StatusPage;
- chartType: string;
-}) => {
- const theme = useTheme();
-
- if (monitor.type === "hardware") {
- if (statusPage.showInfrastructure === false) return null;
- return ;
- }
-
- if (statusPage.showCharts === false) return null;
-
- const checks = monitor.checks?.slice().reverse() ?? [];
- return (
-
- {chartType === "histogram" ? (
-
- ) : (
-
- )}
-
- );
-};
-
-export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
- const theme = useTheme();
- const { t } = useTranslation();
- const showURL = useSelector((state: RootState) => state.ui?.showURL);
- const [chartType, setChartType] = useState<"histogram" | "heatmap">("histogram");
-
- return (
-
- {statusPage.showCharts && (
-
- {t("pages.statusPages.monitorsList.chartTypeHeatmap")}
- setChartType(e.target.checked ? "histogram" : "heatmap")}
- />
-
- {t("pages.statusPages.monitorsList.chartTypeHistogram")}
-
-
- )}
-
- {monitors.map((monitor) => (
-
-
-
-
- ))}
-
- );
-};
diff --git a/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx b/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx
deleted file mode 100644
index 49f70d952..000000000
--- a/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx
+++ /dev/null
@@ -1,173 +0,0 @@
-import {
- AlertTriangle,
- CircleCheck,
- CircleX,
- Loader,
- PauseCircle,
- ShieldAlert,
- Wrench,
-} from "lucide-react";
-import Stack from "@mui/material/Stack";
-import Typography from "@mui/material/Typography";
-
-import { useTranslation } from "react-i18next";
-import { useTheme } from "@mui/material";
-import type { Monitor, MonitorStatus } from "@/Types/Monitor";
-
-type SemanticTone = "up" | "down" | "warn" | "info";
-
-interface StatusDisplay {
- icon: JSX.Element;
- msg?: string;
- tone: SemanticTone;
-}
-
-const getMonitorStatus = (monitors: Monitor[], t: Function): StatusDisplay => {
- if (monitors.length === 0) {
- return {
- msg: t("pages.statusPages.statusBar.noMonitors"),
- tone: "warn",
- icon: ,
- };
- }
-
- const allOf = (...statuses: MonitorStatus[]) =>
- monitors.every((m) => statuses.includes(m.status));
- const someOf = (...statuses: MonitorStatus[]) =>
- monitors.some((m) => statuses.includes(m.status));
- const noneOf = (...statuses: MonitorStatus[]) =>
- monitors.every((m) => !statuses.includes(m.status));
-
- if (allOf("up")) {
- return {
- msg: t("pages.statusPages.statusBar.allUp"),
- tone: "up",
- icon: ,
- };
- }
- if (allOf("breached")) {
- return {
- msg: t("pages.statusPages.statusBar.allBreached"),
- tone: "down",
- icon: ,
- };
- }
- if (allOf("maintenance")) {
- return {
- msg: t("pages.statusPages.statusBar.allMaintenance"),
- tone: "warn",
- icon: ,
- };
- }
- if (allOf("down")) {
- return {
- msg: t("pages.statusPages.statusBar.allDown"),
- tone: "down",
- icon: ,
- };
- }
- if (allOf("paused")) {
- return {
- msg: t("pages.statusPages.statusBar.allPaused"),
- tone: "warn",
- icon: ,
- };
- }
- if (allOf("initializing")) {
- return {
- msg: t("pages.statusPages.statusBar.allInitializing"),
- tone: "warn",
- icon: ,
- };
- }
-
- // Breached takes highest priority in mixed states
- if (someOf("breached") && someOf("down")) {
- return {
- msg: t("pages.statusPages.statusBar.breachedAndDown"),
- tone: "down",
- icon: ,
- };
- }
- if (someOf("breached")) {
- return {
- msg: t("pages.statusPages.statusBar.breached"),
- tone: "down",
- icon: ,
- };
- }
- if (someOf("maintenance") && someOf("down")) {
- return {
- msg: t("pages.statusPages.statusBar.maintenanceAndDown"),
- tone: "down",
- icon: ,
- };
- }
- if (someOf("maintenance") && noneOf("down")) {
- return {
- msg: t("pages.statusPages.statusBar.maintenance"),
- tone: "warn",
- icon: ,
- };
- }
- if (someOf("down")) {
- return {
- msg: t("pages.statusPages.statusBar.degraded"),
- tone: "warn",
- icon: ,
- };
- }
- if (someOf("paused")) {
- return {
- msg: t("pages.statusPages.statusBar.partiallyPaused"),
- tone: "warn",
- icon: ,
- };
- }
- if (someOf("initializing")) {
- return {
- msg: t("pages.statusPages.statusBar.initializing"),
- tone: "info",
- icon: ,
- };
- }
-
- return {
- msg: t("pages.statusPages.statusBar.unknown"),
- tone: "warn",
- icon: ,
- };
-};
-
-const toneToCssVar: Record = {
- up: "var(--sp-up)",
- down: "var(--sp-down)",
- warn: "var(--sp-warn)",
- info: "var(--sp-up)",
-};
-
-interface StatusBarProps {
- monitors: Monitor[];
-}
-
-export const StatusBar = ({ monitors }: StatusBarProps) => {
- const theme = useTheme();
- const { t } = useTranslation();
- const monitorsStatus = getMonitorStatus(monitors, t);
-
- return (
-
- {monitorsStatus.icon}
- {monitorsStatus.msg}
-
- );
-};
diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx
index a4864d748..992b2523f 100644
--- a/client/src/Pages/StatusPage/Status/index.tsx
+++ b/client/src/Pages/StatusPage/Status/index.tsx
@@ -1,18 +1,20 @@
import { BasePage, BaseFallback } from "@/Components/design-elements";
-import { StatusBar } from "@/Pages/StatusPage/Status/Components/StatusBar";
-import { MonitorsList } from "@/Pages/StatusPage/Status/Components/MonitorsList";
import Typography from "@mui/material/Typography";
import { Link } from "react-router-dom";
import Stack from "@mui/material/Stack";
-import Box from "@mui/material/Box";
-import { useMediaQuery, useTheme } from "@mui/material";
+import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useLocation, useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
-import type { StatusPage, StatusPageResponse, StatusPageTheme } from "@/Types/StatusPage";
+import {
+ DEFAULT_STATUS_PAGE_THEME,
+ type StatusPage,
+ type StatusPageResponse,
+ type StatusPageTheme,
+} from "@/Types/StatusPage";
import { HeaderStatusPageControls } from "./Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "./themes/StatusPageThemeProvider";
import { RefinedStatusPage } from "./themes/refined/RefinedStatusPage";
@@ -26,8 +28,9 @@ type ThemedRendererProps = {
monitors: (Monitor & { checks?: Monitor["recentChecks"] })[];
};
-const THEMED_RENDERERS: Partial<
- Record>
+const THEMED_RENDERERS: Record<
+ StatusPageTheme,
+ React.ComponentType
> = {
refined: RefinedStatusPage,
modern: ModernStatusPage,
@@ -41,7 +44,6 @@ const StatusPageView = () => {
const { url } = useParams();
const isAdmin = useIsAdmin();
const location = useLocation();
- const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isPublic = location.pathname.startsWith("/status/public");
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
@@ -86,103 +88,51 @@ const StatusPageView = () => {
);
}
- let sx: React.CSSProperties = {};
- if (isPublic) {
- sx.paddingTop = theme.spacing(20);
- sx.paddingLeft = isSmall ? "5vw" : "20vw";
- sx.paddingRight = isSmall ? "5vw" : "20vw";
- }
-
- const logoSrc = statusPage.logo?.data
- ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
- : null;
-
- const ThemedRenderer = statusPage.theme
- ? THEMED_RENDERERS[statusPage.theme]
- : undefined;
- const themedRenderer = ThemedRenderer ? (
+ const ThemedRenderer =
+ THEMED_RENDERERS[statusPage.theme ?? DEFAULT_STATUS_PAGE_THEME] ??
+ THEMED_RENDERERS[DEFAULT_STATUS_PAGE_THEME];
+ const themedRenderer = (
- ) : null;
+ );
- if (themedRenderer) {
- // Public route: render directly on the viewport, themed background covers everything.
- if (isPublic) {
- return (
-
- {themedRenderer}
-
- );
- }
-
- // Admin preview: wrap in a mock browser frame inside the admin shell.
- const publicUrl = `${window.location.origin}/status/public/${statusPage.url}`;
+ // Public route: render directly on the viewport, themed background covers everything.
+ if (isPublic) {
return (
-
-
-
- {themedRenderer}
-
-
+ {themedRenderer}
+
);
}
+ // Admin preview: wrap in a mock browser frame inside the admin shell.
+ const publicUrl = `${window.location.origin}/status/public/${statusPage.url}`;
return (
-
-
+
-
- {logoSrc && (
-
- )}
-
-
-
-
+ {themedRenderer}
+
+
);
};
diff --git a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx
index 294198330..ffff8df2e 100644
--- a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx
@@ -13,7 +13,6 @@ import {
type StatusPageThemeMode,
} from "@/Types/StatusPage";
import { themeTokens, toCssVars, type StatusPageThemeTokens } from "./tokens";
-import "./theme-overrides.css";
type ResolvedMode = "light" | "dark";
diff --git a/client/src/Pages/StatusPage/Status/themes/theme-overrides.css b/client/src/Pages/StatusPage/Status/themes/theme-overrides.css
deleted file mode 100644
index b66257d18..000000000
--- a/client/src/Pages/StatusPage/Status/themes/theme-overrides.css
+++ /dev/null
@@ -1,156 +0,0 @@
-/*
- * Scoped styling for the public status page themes.
- * Every selector is prefixed with [data-status-theme="..."] so these styles
- * only apply inside the StatusPageThemeProvider subtree.
- *
- * Colour tokens come from CSS custom properties set by the provider
- * (see tokens.ts → toCssVars). This file adds structural and surface rules
- * that a plain token swap can't express.
- */
-
-/* --- shared: push tokens onto MUI surfaces inside the themed tree --- */
-[data-status-theme] {
- color: var(--sp-text);
-}
-
-[data-status-theme] .sp-card,
-[data-status-theme] .MuiBox-root.sp-card {
- background-color: var(--sp-surface) !important;
- border-color: var(--sp-border) !important;
- border-radius: var(--sp-radius) !important;
- color: var(--sp-text);
-}
-
-[data-status-theme] .sp-heading {
- color: var(--sp-text);
-}
-
-[data-status-theme] .sp-badge {
- background-color: var(--sp-up-soft) !important;
- color: var(--sp-up) !important;
- border-radius: 999px;
- padding: 2px 10px;
- font-weight: 600;
- letter-spacing: 0.04em;
-}
-
-/* Status bar / hero */
-[data-status-theme] .sp-hero {
- border-radius: var(--sp-radius);
-}
-
-/* --- refined: soft shadow, small accent on card --- */
-[data-status-theme="refined"] .sp-card {
- box-shadow:
- 0 1px 2px rgba(10, 16, 32, 0.06),
- 0 6px 18px rgba(10, 16, 32, 0.08) !important;
-}
-[data-status-theme="refined"] .sp-hero-title {
- font-weight: 600;
- letter-spacing: -0.01em;
-}
-
-/* --- modern: stronger shadow, larger radius, pulse dot + staggered fade --- */
-[data-status-theme="modern"] .sp-card {
- box-shadow:
- 0 2px 4px rgba(16, 24, 40, 0.04),
- 0 12px 32px rgba(16, 24, 40, 0.06) !important;
- border-radius: 16px !important;
- opacity: 0;
- transform: translateY(8px);
- animation: sp-card-fadein 0.5s forwards;
-}
-[data-status-theme="modern"] .sp-card:nth-of-type(1) {
- animation-delay: 0.05s;
-}
-[data-status-theme="modern"] .sp-card:nth-of-type(2) {
- animation-delay: 0.1s;
-}
-[data-status-theme="modern"] .sp-card:nth-of-type(3) {
- animation-delay: 0.15s;
-}
-[data-status-theme="modern"] .sp-card:nth-of-type(4) {
- animation-delay: 0.2s;
-}
-[data-status-theme="modern"] .sp-card:nth-of-type(5) {
- animation-delay: 0.25s;
-}
-[data-status-theme="modern"] .sp-card:nth-of-type(n + 6) {
- animation-delay: 0.3s;
-}
-@keyframes sp-card-fadein {
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-[data-status-theme="modern"] .sp-hero-title {
- font-size: 22px;
- font-weight: 700;
- letter-spacing: -0.02em;
-}
-
-/* --- bold: large type, conic-gradient hero accent when no logo --- */
-[data-status-theme="bold"] .sp-hero {
- background:
- linear-gradient(135deg, var(--sp-up-soft), transparent 60%), var(--sp-surface) !important;
- border: 1px solid var(--sp-border) !important;
-}
-[data-status-theme="bold"] .sp-hero-title {
- font-size: 32px;
- font-weight: 800;
- letter-spacing: -0.02em;
- line-height: 1.1;
- color: var(--sp-text) !important;
-}
-[data-status-theme="bold"] .sp-card {
- border-radius: 18px !important;
- border: 1px solid var(--sp-border) !important;
-}
-
-/* --- editorial: serif headings, hairline dividers instead of cards --- */
-[data-status-theme="editorial"] .sp-heading {
- font-family: Georgia, "Iowan Old Style", "Palatino Linotype", serif;
- letter-spacing: -0.01em;
-}
-[data-status-theme="editorial"] .sp-card {
- background-color: transparent !important;
- border: 0 !important;
- border-bottom: 1px solid var(--sp-border) !important;
- border-radius: 0 !important;
- box-shadow: none !important;
- padding-left: 0 !important;
- padding-right: 0 !important;
-}
-[data-status-theme="editorial"] .sp-card:last-of-type {
- border-bottom: 0 !important;
-}
-[data-status-theme="editorial"] .sp-hero {
- background: transparent !important;
- color: var(--sp-text) !important;
- border-bottom: 2px solid var(--sp-text) !important;
- border-radius: 0 !important;
- box-shadow: none !important;
-}
-[data-status-theme="editorial"] .sp-hero-title {
- font-family: Georgia, "Iowan Old Style", "Palatino Linotype", serif;
- font-weight: 700;
-}
-[data-status-theme="editorial"] .sp-badge {
- background-color: transparent !important;
- color: var(--sp-text-muted) !important;
- border: 1px solid var(--sp-border) !important;
- border-radius: 0 !important;
- text-transform: uppercase;
- font-size: 10px;
- letter-spacing: 0.12em;
-}
-
-/* Reduce motion */
-@media (prefers-reduced-motion: reduce) {
- [data-status-theme] .sp-card {
- animation: none;
- opacity: 1;
- transform: none;
- }
-}
From bab65770ce3c074ae9341e5483cdb7d6ade14337 Mon Sep 17 00:00:00 2001
From: gorkem-bwl
Date: Tue, 28 Apr 2026 16:52:16 -0400
Subject: [PATCH 08/19] refactor(status-page): simplify renderer lookup,
extract route prefix, memoize wrapper style
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four small follow-ups from a code review pass:
1. Drop the redundant `?? THEMED_RENDERERS[DEFAULT_STATUS_PAGE_THEME]`
fallback in Status/index.tsx — the map is now a total Record, so the
first lookup cannot return undefined.
2. Extract the literal "/status/public" into PUBLIC_STATUS_PAGE_PREFIX in
Types/StatusPage.ts and reuse from the three runtime call sites
(Status/index.tsx, HeaderStatusPageControls.tsx, StatusPagesTable.tsx).
The Routes/index.tsx route definition keeps its string literal — the
route table is otherwise all literals and the prefix only matters when
it's interpolated.
3. Delete two narrating comments above the public/admin branches in
Status/index.tsx — the JSX is self-describing.
4. Move the inline `style` object construction in StatusPageThemeProvider
into the existing useMemo so the wrapper div receives a stable style
reference. The previous spread defeated the surrounding memoization on
every render.
---
.../Components/HeaderStatusPageControls.tsx | 4 ++--
client/src/Pages/StatusPage/Status/index.tsx | 11 ++++------
.../Status/themes/StatusPageThemeProvider.tsx | 22 ++++++++++---------
.../Components/StatusPagesTable.tsx | 4 ++--
client/src/Types/StatusPage.ts | 2 ++
5 files changed, 22 insertions(+), 21 deletions(-)
diff --git a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx
index 2cc2395bb..ba3a5940d 100644
--- a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx
+++ b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx
@@ -8,7 +8,7 @@ import { Settings, ExternalLink } from "lucide-react";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
-import type { StatusPage } from "@/Types/StatusPage";
+import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
interface HeaderStatusPageControlsProps {
isAdmin: boolean;
@@ -50,7 +50,7 @@ export const HeaderStatusPageControls = ({
{
window.open(
- `/status/public/${statusPage.url}`,
+ `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`,
"_blank",
"noopener,noreferrer"
);
diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx
index 992b2523f..4757de5a3 100644
--- a/client/src/Pages/StatusPage/Status/index.tsx
+++ b/client/src/Pages/StatusPage/Status/index.tsx
@@ -11,6 +11,7 @@ import { useGet } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import {
DEFAULT_STATUS_PAGE_THEME,
+ PUBLIC_STATUS_PAGE_PREFIX,
type StatusPage,
type StatusPageResponse,
type StatusPageTheme,
@@ -44,7 +45,7 @@ const StatusPageView = () => {
const { url } = useParams();
const isAdmin = useIsAdmin();
const location = useLocation();
- const isPublic = location.pathname.startsWith("/status/public");
+ const isPublic = location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
@@ -88,9 +89,7 @@ const StatusPageView = () => {
);
}
- const ThemedRenderer =
- THEMED_RENDERERS[statusPage.theme ?? DEFAULT_STATUS_PAGE_THEME] ??
- THEMED_RENDERERS[DEFAULT_STATUS_PAGE_THEME];
+ const ThemedRenderer = THEMED_RENDERERS[statusPage.theme ?? DEFAULT_STATUS_PAGE_THEME];
const themedRenderer = (
{
/>
);
- // Public route: render directly on the viewport, themed background covers everything.
if (isPublic) {
return (
{
);
}
- // Admin preview: wrap in a mock browser frame inside the admin shell.
- const publicUrl = `${window.location.origin}/status/public/${statusPage.url}`;
+ const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
return (
toCssVars(tokens) as React.CSSProperties, [tokens]);
+ const style = useMemo(
+ () =>
+ transparent
+ ? (toCssVars(tokens) as React.CSSProperties)
+ : {
+ ...(toCssVars(tokens) as React.CSSProperties),
+ background: tokens.bg,
+ minHeight: "100vh",
+ },
+ [tokens, transparent]
+ );
const value = useMemo(
() => ({ theme: resolvedTheme, mode: resolvedMode, tokens }),
@@ -118,15 +128,7 @@ export const StatusPageThemeProvider = ({
data-status-theme={resolvedTheme}
data-status-mode={resolvedMode}
data-status-transparent={transparent ? "" : undefined}
- style={
- transparent
- ? (style as React.CSSProperties)
- : {
- ...style,
- background: tokens.bg,
- minHeight: "100vh",
- }
- }
+ style={style}
>
{children}
diff --git a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx
index 8f72931d6..34daf203c 100644
--- a/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx
+++ b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable.tsx
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom";
-import type { StatusPage } from "@/Types/StatusPage";
+import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
interface StatusPagesTableProps {
data: StatusPage[];
@@ -50,7 +50,7 @@ export const StatusPagesTable = ({
const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => {
if (row.isPublished) {
e.stopPropagation();
- const url = `/status/public/${row.url}`;
+ const url = `${PUBLIC_STATUS_PAGE_PREFIX}/${row.url}`;
window.open(url, "_blank", "noopener,noreferrer");
}
};
diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts
index 6388ccfd9..5b8c4acfa 100644
--- a/client/src/Types/StatusPage.ts
+++ b/client/src/Types/StatusPage.ts
@@ -29,6 +29,8 @@ export const STATUS_PAGE_THEME_MODES = ["auto", "light", "dark"] as const;
export type StatusPageThemeMode = (typeof STATUS_PAGE_THEME_MODES)[number];
export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto";
+export const PUBLIC_STATUS_PAGE_PREFIX = "/status/public";
+
export interface StatusPage {
id: string;
userId: string;
From 5716b5eea4660930f350967329f42198e889cb38 Mon Sep 17 00:00:00 2001
From: gorkem-bwl
Date: Tue, 28 Apr 2026 23:58:13 -0400
Subject: [PATCH 09/19] feat(settings): explanatory body in Remove monitors
confirmation modal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The remove-all-monitors confirmation dialog only had a one-line title
and an empty body, which looked awkward and didn't communicate what was
actually about to be deleted alongside the monitors themselves.
- Adds a paragraph explaining that check history, incidents, scheduled
maintenance windows, and dependent status pages will also be removed,
with an inline tip to export monitors first to keep a backup.
- The Confirm button now uses confirmColor='error' to make the
destructive action visually distinct, with a localized
'Remove monitors' label.
- Translated to all 16 locales, with terminology aligned to each
locale's existing usage (e.g. zh-CN/zh-TW use 监控/監控 to match the
existing button label, vi uses 'trình giám sát', etc.).
---
client/src/Pages/Settings/index.tsx | 8 +++++++-
client/src/locales/ar.json | 5 +++--
client/src/locales/cs.json | 7 ++++---
client/src/locales/de.json | 5 +++--
client/src/locales/en.json | 3 ++-
client/src/locales/es.json | 7 ++++---
client/src/locales/fi.json | 5 +++--
client/src/locales/fr.json | 7 ++++---
client/src/locales/ja.json | 3 ++-
client/src/locales/pt-BR.json | 7 ++++---
client/src/locales/ru.json | 5 +++--
client/src/locales/th.json | 5 +++--
client/src/locales/tr.json | 7 ++++---
client/src/locales/uk.json | 5 +++--
client/src/locales/vi.json | 5 +++--
client/src/locales/zh-CN.json | 5 +++--
client/src/locales/zh-TW.json | 7 ++++---
17 files changed, 59 insertions(+), 37 deletions(-)
diff --git a/client/src/Pages/Settings/index.tsx b/client/src/Pages/Settings/index.tsx
index 1a9724916..fc3fceceb 100644
--- a/client/src/Pages/Settings/index.tsx
+++ b/client/src/Pages/Settings/index.tsx
@@ -974,7 +974,13 @@ export const SettingsPage = () => {
setIsDemoMonitorsDialogOpen(false);
}}
loading={isDeletingAllMonitors}
- />
+ confirmColor="error"
+ confirmText={t("common.buttons.removeMonitors")}
+ >
+
+ {t("pages.settings.form.removeMonitors.dialog.paragraph")}
+
+
Date: Wed, 29 Apr 2026 10:01:43 -0700
Subject: [PATCH 10/19] fix build error
---
client/src/Pages/Incidents/Components/CardDetails.tsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/client/src/Pages/Incidents/Components/CardDetails.tsx b/client/src/Pages/Incidents/Components/CardDetails.tsx
index 82ececaa8..b6c1288dd 100644
--- a/client/src/Pages/Incidents/Components/CardDetails.tsx
+++ b/client/src/Pages/Incidents/Components/CardDetails.tsx
@@ -13,7 +13,6 @@ import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import { getIncidentsDuration } from "@/Pages/Incidents/utils";
-import { theme } from "@/Utils/Theme/Theme";
interface CardDetailsProps {
incident: Incident | null;
From 74a12754b6e14491afc0f436e61cd0353b767162 Mon Sep 17 00:00:00 2001
From: Alex Holliday
Date: Wed, 29 Apr 2026 10:33:46 -0700
Subject: [PATCH 11/19] fix controller architecture
---
server/src/config/services.ts | 2 +-
.../src/controllers/statusPageController.ts | 32 ++------
.../src/service/business/statusPageService.ts | 37 +++++++--
.../unit/services/statusPageService.test.ts | 81 ++++++++++++++++---
4 files changed, 107 insertions(+), 45 deletions(-)
diff --git a/server/src/config/services.ts b/server/src/config/services.ts
index c7e1a4469..b6cbb3a30 100644
--- a/server/src/config/services.ts
+++ b/server/src/config/services.ts
@@ -348,7 +348,7 @@ export const initializeServices = async ({
incidentsRepository,
});
- const statusPageService = new StatusPageService(statusPagesRepository);
+ const statusPageService = new StatusPageService(statusPagesRepository, settingsService);
const services = {
settingsService,
diff --git a/server/src/controllers/statusPageController.ts b/server/src/controllers/statusPageController.ts
index 2e338f787..190c3a9fd 100644
--- a/server/src/controllers/statusPageController.ts
+++ b/server/src/controllers/statusPageController.ts
@@ -12,7 +12,6 @@ import { IStatusPageService } from "@/service/business/statusPageService.js";
import { IMonitorsRepository } from "@/repositories/index.js";
import { ISettingsService } from "@/service/system/settingsService.js";
import { NormalizeData } from "@/utils/dataUtils.js";
-import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, type StatusPage } from "@/types/statusPage.js";
const SERVICE_NAME = "statusPageController";
@@ -40,19 +39,6 @@ class StatusPageController implements IStatusPageController {
return StatusPageController.SERVICE_NAME;
}
- private withoutThemeFields = (body: Record): Record => {
- const rest = { ...body };
- delete rest.theme;
- delete rest.themeMode;
- return rest;
- };
-
- private applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({
- ...statusPage,
- theme: DEFAULT_STATUS_PAGE_THEME,
- themeMode: DEFAULT_STATUS_PAGE_THEME_MODE,
- });
-
createStatusPage = async (req: Request, res: Response, next: NextFunction) => {
try {
createStatusPageBodyValidation.parse(req.body);
@@ -60,16 +46,14 @@ class StatusPageController implements IStatusPageController {
imageValidation.parse(req.file);
}
- const themesEnabled = this.settingsService.areStatusPageThemesEnabled();
- const body = themesEnabled ? req.body : this.withoutThemeFields(req.body);
const teamId = requireTeamId(req?.user?.teamId);
const userId = requireUserId(req?.user?.id);
- const statusPage = await this.statusPageService.createStatusPage(userId, teamId, req.file, body);
+ const statusPage = await this.statusPageService.createStatusPage(userId, teamId, req.file, req.body);
return res.status(200).json({
success: true,
msg: "Status page created successfully",
- data: themesEnabled ? statusPage : this.applyDefaultTheme(statusPage),
+ data: statusPage,
});
} catch (error) {
next(error);
@@ -83,21 +67,19 @@ class StatusPageController implements IStatusPageController {
imageValidation.parse(req.file);
}
- const themesEnabled = this.settingsService.areStatusPageThemesEnabled();
- const body = themesEnabled ? req.body : this.withoutThemeFields(req.body);
const teamId = requireTeamId(req?.user?.teamId);
const statusPageId = req.params.id as string;
if (!statusPageId) {
throw new AppError({ message: "Status page ID is required", status: 400 });
}
- const statusPage = await this.statusPageService.updateStatusPage(statusPageId, teamId, req.file, body);
+ const statusPage = await this.statusPageService.updateStatusPage(statusPageId, teamId, req.file, req.body);
if (statusPage === null) {
throw new AppError({ message: "Status page not found", status: 404 });
}
res.status(200).json({
success: true,
msg: "Status page updated successfully",
- data: themesEnabled ? statusPage : this.applyDefaultTheme(statusPage),
+ data: statusPage,
});
} catch (error) {
next(error);
@@ -143,12 +125,11 @@ class StatusPageController implements IStatusPageController {
}
return { ...monitor, checks: normalizedChecks };
});
- const themesEnabled = this.settingsService.areStatusPageThemesEnabled();
return res.status(200).json({
success: true,
msg: "Status page retrieved successfully",
data: {
- statusPage: themesEnabled ? statusPage : this.applyDefaultTheme(statusPage),
+ statusPage,
monitors: normalizedMonitors,
},
});
@@ -161,11 +142,10 @@ class StatusPageController implements IStatusPageController {
const teamId = requireTeamId(req.user?.teamId);
const statusPages = await this.statusPageService.getStatusPagesByTeamId(teamId);
- const themesEnabled = this.settingsService.areStatusPageThemesEnabled();
return res.status(200).json({
success: true,
msg: "Status pages retrieved successfully",
- data: themesEnabled ? statusPages : statusPages.map((sp) => this.applyDefaultTheme(sp)),
+ data: statusPages,
});
} catch (error) {
next(error);
diff --git a/server/src/service/business/statusPageService.ts b/server/src/service/business/statusPageService.ts
index 971f6897f..179461f25 100644
--- a/server/src/service/business/statusPageService.ts
+++ b/server/src/service/business/statusPageService.ts
@@ -1,5 +1,7 @@
import { type IStatusPagesRepository } from "@/repositories/index.js";
-import { StatusPage } from "@/types/index.js";
+import { ISettingsService } from "@/service/system/settingsService.js";
+import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, StatusPage } from "@/types/index.js";
+
export interface IStatusPageService {
createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise;
getStatusPageByUrl(url: string): Promise;
@@ -11,29 +13,52 @@ export interface IStatusPageService {
export class StatusPageService implements IStatusPageService {
private statusPagesRepository: IStatusPagesRepository;
- constructor(statusPagesRepository: IStatusPagesRepository) {
+ private settingsService: ISettingsService;
+ constructor(statusPagesRepository: IStatusPagesRepository, settingsService: ISettingsService) {
this.statusPagesRepository = statusPagesRepository;
+ this.settingsService = settingsService;
}
+ private withoutThemeFields = (data: Partial): Partial => {
+ const { theme: _theme, themeMode: _themeMode, ...rest } = data;
+ return rest;
+ };
+
+ private applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({
+ ...statusPage,
+ theme: DEFAULT_STATUS_PAGE_THEME,
+ themeMode: DEFAULT_STATUS_PAGE_THEME_MODE,
+ });
+
+ private normalizeTheme = (statusPage: StatusPage): StatusPage =>
+ this.settingsService.areStatusPageThemesEnabled() ? statusPage : this.applyDefaultTheme(statusPage);
+
+ private normalizeInput = (data: Partial): Partial =>
+ this.settingsService.areStatusPageThemesEnabled() ? data : this.withoutThemeFields(data);
+
createStatusPage = async (
userId: string,
teamId: string,
image: Express.Multer.File | undefined,
data: Partial
): Promise => {
- return await this.statusPagesRepository.create(userId, teamId, image, data);
+ const created = await this.statusPagesRepository.create(userId, teamId, image, this.normalizeInput(data));
+ return this.normalizeTheme(created);
};
getStatusPageByUrl = async (url: string): Promise => {
- return await this.statusPagesRepository.findByUrl(url);
+ const statusPage = await this.statusPagesRepository.findByUrl(url);
+ return this.normalizeTheme(statusPage);
};
getStatusPagesByTeamId = async (teamId: string): Promise => {
- return await this.statusPagesRepository.findByTeamId(teamId);
+ const statusPages = await this.statusPagesRepository.findByTeamId(teamId);
+ return statusPages.map((sp) => this.normalizeTheme(sp));
};
updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial): Promise => {
- return await this.statusPagesRepository.updateById(id, teamId, image, data);
+ const updated = await this.statusPagesRepository.updateById(id, teamId, image, this.normalizeInput(data));
+ return this.normalizeTheme(updated);
};
deleteStatusPage = async (statusPageId: string, teamId: string): Promise => {
diff --git a/server/test/unit/services/statusPageService.test.ts b/server/test/unit/services/statusPageService.test.ts
index ea3d1ce82..2deac9897 100644
--- a/server/test/unit/services/statusPageService.test.ts
+++ b/server/test/unit/services/statusPageService.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it, jest } from "@jest/globals";
import { StatusPageService } from "../../../src/service/business/statusPageService.ts";
import type { IStatusPagesRepository } from "../../../src/repositories/status-pages/IStatusPagesRepository.ts";
+import type { ISettingsService } from "../../../src/service/system/settingsService.ts";
import type { StatusPage } from "../../../src/types/index.ts";
+import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/types/index.ts";
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -13,6 +15,8 @@ const makeStatusPage = (overrides?: Partial): StatusPage =>
url: "my-status-page",
companyName: "Test Co",
monitors: ["mon-1"],
+ theme: "modern",
+ themeMode: "dark",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
@@ -28,18 +32,24 @@ const createRepo = () =>
removeMonitorFromStatusPages: jest.fn().mockResolvedValue(1),
}) as unknown as jest.Mocked;
-const createService = () => {
+const createSettingsService = (themesEnabled: boolean) =>
+ ({
+ areStatusPageThemesEnabled: jest.fn().mockReturnValue(themesEnabled),
+ }) as unknown as jest.Mocked;
+
+const createService = (themesEnabled = true) => {
const repo = createRepo();
- const service = new StatusPageService(repo);
- return { service, repo };
+ const settingsService = createSettingsService(themesEnabled);
+ const service = new StatusPageService(repo, settingsService);
+ return { service, repo, settingsService };
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe("StatusPageService", () => {
describe("createStatusPage", () => {
- it("delegates to repository with all parameters", async () => {
- const { service, repo } = createService();
+ it("delegates to repository with all parameters when themes enabled", async () => {
+ const { service, repo } = createService(true);
const data = { companyName: "New Co" };
const file = { originalname: "logo.png" } as Express.Multer.File;
@@ -50,39 +60,75 @@ describe("StatusPageService", () => {
});
it("passes undefined image when not provided", async () => {
- const { service, repo } = createService();
+ const { service, repo } = createService(true);
await service.createStatusPage("user-1", "team-1", undefined, {});
expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", undefined, {});
});
+
+ it("strips theme fields from input and applies defaults to result when themes disabled", async () => {
+ const { service, repo } = createService(false);
+ const data = { companyName: "New Co", theme: "bold" as const, themeMode: "light" as const };
+
+ const result = await service.createStatusPage("user-1", "team-1", undefined, data);
+
+ expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", undefined, { companyName: "New Co" });
+ expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME);
+ expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE);
+ });
});
describe("getStatusPageByUrl", () => {
it("delegates to repository", async () => {
- const { service, repo } = createService();
+ const { service, repo } = createService(true);
const result = await service.getStatusPageByUrl("my-status-page");
expect(repo.findByUrl).toHaveBeenCalledWith("my-status-page");
expect(result).toEqual(makeStatusPage());
});
+
+ it("applies default theme when themes disabled", async () => {
+ const { service } = createService(false);
+
+ const result = await service.getStatusPageByUrl("my-status-page");
+
+ expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME);
+ expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE);
+ });
});
describe("getStatusPagesByTeamId", () => {
it("delegates to repository", async () => {
- const { service, repo } = createService();
+ const { service, repo } = createService(true);
const result = await service.getStatusPagesByTeamId("team-1");
expect(repo.findByTeamId).toHaveBeenCalledWith("team-1");
expect(result).toEqual([makeStatusPage()]);
});
+
+ it("applies default theme to every result when themes disabled", async () => {
+ const { service, repo } = createService(false);
+ (repo.findByTeamId as jest.Mock).mockResolvedValue([
+ makeStatusPage({ id: "sp-1", theme: "bold" }),
+ makeStatusPage({ id: "sp-2", theme: "editorial" }),
+ ]);
+
+ const result = await service.getStatusPagesByTeamId("team-1");
+
+ expect(result).toHaveLength(2);
+ for (const sp of result) {
+ expect(sp.theme).toBe(DEFAULT_STATUS_PAGE_THEME);
+ expect(sp.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE);
+ }
+ });
});
describe("updateStatusPage", () => {
it("delegates to repository with all parameters", async () => {
- const { service, repo } = createService();
+ const { service, repo } = createService(true);
const updated = makeStatusPage({ companyName: "Updated Co" });
(repo.updateById as jest.Mock).mockResolvedValue(updated);
const file = { originalname: "new-logo.png" } as Express.Multer.File;
@@ -93,19 +139,30 @@ describe("StatusPageService", () => {
expect(result).toBe(updated);
});
- it("passes theme and themeMode through to the repository", async () => {
- const { service, repo } = createService();
+ it("passes theme and themeMode through to the repository when themes enabled", async () => {
+ const { service, repo } = createService(true);
const patch = { theme: "modern" as const, themeMode: "dark" as const };
await service.updateStatusPage("sp-1", "team-1", undefined, patch);
expect(repo.updateById).toHaveBeenCalledWith("sp-1", "team-1", undefined, patch);
});
+
+ it("strips theme fields from input and applies defaults to result when themes disabled", async () => {
+ const { service, repo } = createService(false);
+ const patch = { companyName: "Updated Co", theme: "modern" as const, themeMode: "dark" as const };
+
+ const result = await service.updateStatusPage("sp-1", "team-1", undefined, patch);
+
+ expect(repo.updateById).toHaveBeenCalledWith("sp-1", "team-1", undefined, { companyName: "Updated Co" });
+ expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME);
+ expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE);
+ });
});
describe("deleteStatusPage", () => {
it("delegates to repository and returns deleted page", async () => {
- const { service, repo } = createService();
+ const { service, repo } = createService(true);
const result = await service.deleteStatusPage("sp-1", "team-1");
From fcb4fbf27722b91e6299a84141e037518175ed2e Mon Sep 17 00:00:00 2001
From: Alex Holliday
Date: Wed, 29 Apr 2026 10:35:35 -0700
Subject: [PATCH 12/19] fix optional types
---
client/src/Types/StatusPage.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts
index 6388ccfd9..ed592fc07 100644
--- a/client/src/Types/StatusPage.ts
+++ b/client/src/Types/StatusPage.ts
@@ -51,8 +51,8 @@ export interface StatusPage {
showAdminLoginLink: boolean;
showInfrastructure: boolean;
customCSS: string;
- theme?: StatusPageTheme;
- themeMode?: StatusPageThemeMode;
+ theme: StatusPageTheme;
+ themeMode: StatusPageThemeMode;
createdAt: string;
updatedAt: string;
}
From aa7051095ac604993b066bcc50b3bf49879d7ff7 Mon Sep 17 00:00:00 2001
From: gorkem-bwl
Date: Wed, 29 Apr 2026 13:49:58 -0400
Subject: [PATCH 13/19] docs(client): document frontend conventions and enforce
via ESLint
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Capture the five recurring code-review patterns from the recent PR series
(#3591–#3596) as enforceable rules so they don't have to be raised again
in review.
## Conventions
- Prefer MUI native props over `sx` (e.g. color={...}, mt={...}, width={...}).
- Use the full theme path for colors: theme.palette.text.secondary, never
the "text.secondary" shorthand string. The shorthand is invisible to
grep, which makes future audits and refactors miss callsites.
- No hardcoded numeric or string literals — use LAYOUT, typographyLevels,
theme.shape.borderRadius, theme.palette tokens.
- Use useTheme() inside components; don't import { theme } from
@/Utils/Theme/Theme (factory output, not a live mode-aware instance).
- Pair runtime tuples with derived types so they can't drift.
## Files
- docs/frontend-conventions.md — long-form doc with before/after examples
drawn from the actual review fixes, plus a pre-PR checklist.
- CLAUDE.md — short summary in the Code Conventions section pointing to
the doc, so it's loaded into agent context at session start.
## ESLint enforcement
- client/.eslintrc.cjs — four no-restricted-syntax selectors for each rule:
- color="…" / bgcolor="…" / borderColor="…" with a dotted shorthand value
- sx={{ key: ... }} where key has a native equivalent, only on hoistable
components (Box, Stack, Typography, Grid, Container, Paper, Card*,
Divider, Link, Chip) — Dialog/Drawer/Menu etc. only accept sx.
- import { theme } from "@/Utils/Theme/Theme"
- Typography fontSize={13} (the body default — drop the prop).
- TypeScript files are now actually parsed: added @typescript-eslint/parser
and @typescript-eslint/eslint-plugin and a TS override that extends the
React plugin configs. Several @typescript-eslint/recommended rules
disabled to avoid noise on the existing codebase.
- client/package.json — lint script now covers .ts/.tsx; new lint-strict
script for CI use once the codebase is conformant; --max-warnings 0
removed from `lint` so existing-code warnings don't block the workflow.
## Surface
Running `npm run lint` after this change reports ~280 warnings against
the existing codebase, almost all from rule #1 (sx-vs-native). These are
intentionally warnings, not errors — they surface the work without
blocking. Each warning links to the rule number in the conventions doc
so the reasoning is one click away.
---
CLAUDE.md | 8 +
client/.eslintrc.cjs | 135 ++++++++++++++
client/package-lock.json | 341 +++++++++++++++++++++++++++++++++++
client/package.json | 5 +-
docs/frontend-conventions.md | 211 ++++++++++++++++++++++
5 files changed, 699 insertions(+), 1 deletion(-)
create mode 100644 docs/frontend-conventions.md
diff --git a/CLAUDE.md b/CLAUDE.md
index 244dcfe8f..de7804c07 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -157,6 +157,14 @@ When working on anything related to check scheduling, incident lifecycle, or not
## Code Conventions
+### Frontend conventions (mandatory for `client/src`)
+Read `docs/frontend-conventions.md` before touching any `.tsx` file. Five rules, all enforced in code review:
+1. Prefer MUI native props over `sx` (e.g. `color={…}`, `bgcolor={…}`, `mt={…}` — not `sx={{ color, bgcolor, mt }}`).
+2. Use the full theme path for colors: `color={theme.palette.text.secondary}`, never `color="text.secondary"` (greppability).
+3. No hardcoded literals — use `LAYOUT.*`, `typographyLevels.*`, `theme.shape.borderRadius`, `theme.palette.*`.
+4. Use `useTheme()` inside components; don't `import { theme } from "@/Utils/Theme/Theme"`.
+5. Pair runtime tuples with derived types: `const X = [...] as const; type X = (typeof X)[number]`.
+
### Internationalization
All user-facing strings must use the translation function:
```javascript
diff --git a/client/.eslintrc.cjs b/client/.eslintrc.cjs
index 86d523a6f..f8bd3d31f 100644
--- a/client/.eslintrc.cjs
+++ b/client/.eslintrc.cjs
@@ -1,3 +1,104 @@
+// MUI sx keys that have a native (system) prop equivalent.
+// Flagged when found inside an sx={{ ... }} object literal — see frontend-conventions.md rule #1.
+const SX_NATIVE_EQUIVALENT_KEYS = [
+ "color",
+ "bgcolor",
+ "backgroundColor",
+ "borderRadius",
+ "border",
+ "borderTop",
+ "borderBottom",
+ "borderLeft",
+ "borderRight",
+ "width",
+ "height",
+ "minHeight",
+ "maxWidth",
+ "minWidth",
+ "maxHeight",
+ "p",
+ "pt",
+ "pb",
+ "px",
+ "py",
+ "pl",
+ "pr",
+ "m",
+ "mt",
+ "mb",
+ "mx",
+ "my",
+ "ml",
+ "mr",
+ "display",
+ "textAlign",
+ "fontWeight",
+ "fontSize",
+ "lineHeight",
+ "gap",
+];
+
+// MUI components that accept system props as top-level attributes.
+// sx={{ color, p, mt, ... }} on these can be hoisted into native props.
+// Dialog*, Drawer*, Menu*, Tab*, Toolbar etc. only accept sx — don't flag those.
+const SX_HOISTABLE_COMPONENTS = [
+ "Box",
+ "Stack",
+ "Typography",
+ "Grid",
+ "Container",
+ "Paper",
+ "Card",
+ "CardContent",
+ "CardActions",
+ "CardHeader",
+ "Divider",
+ "Link",
+ "Chip",
+];
+
+const sxKeySelector = SX_HOISTABLE_COMPONENTS.flatMap((component) =>
+ SX_NATIVE_EQUIVALENT_KEYS.map(
+ (k) =>
+ `JSXOpeningElement[name.name='${component}'] > JSXAttribute[name.name='sx'] > JSXExpressionContainer > ObjectExpression > Property[key.name='${k}']`
+ )
+).join(", ");
+
+const FRONTEND_CONVENTION_RULES = {
+ // Rule #2 — color/bgcolor/borderColor as a string shorthand like "text.secondary".
+ // Catches: — should be color={theme.palette.text.secondary}.
+ "no-restricted-syntax": [
+ "warn",
+ {
+ selector:
+ "JSXAttribute[name.name=/^(color|bgcolor|borderColor)$/] > Literal[value=/\\./]",
+ message:
+ 'Use the full theme path instead of a string shorthand. e.g. color={theme.palette.text.secondary} not color="text.secondary". See docs/frontend-conventions.md rule #2.',
+ },
+ {
+ // Rule #1 — sx={{ key: ... }} where key has a native prop equivalent.
+ selector: sxKeySelector,
+ message:
+ "Hoist this key out of `sx` onto the component as a native MUI prop (e.g. color={...}, mt={...}, width={...}). See docs/frontend-conventions.md rule #1.",
+ },
+ {
+ // Rule #4 — importing the singleton theme from the Theme module.
+ // Catches: import { theme } from "@/Utils/Theme/Theme" — should be useTheme() inside the component.
+ selector:
+ "ImportDeclaration[source.value='@/Utils/Theme/Theme'] > ImportSpecifier[imported.name='theme']",
+ message:
+ "Don't import { theme } from @/Utils/Theme/Theme — use the useTheme() hook inside the component. See docs/frontend-conventions.md rule #4.",
+ },
+ {
+ // Rule #3 (subset) — Typography fontSize={13} is the body default; drop it.
+ selector:
+ "JSXOpeningElement[name.name='Typography'] JSXAttribute[name.name='fontSize'] > JSXExpressionContainer > Literal[value=13]",
+ message:
+ "fontSize={13} is the Typography body default — drop the prop. For other sizes use typographyLevels tokens. See docs/frontend-conventions.md rule #3.",
+ },
+ ],
+};
+
module.exports = {
root: true,
env: { browser: true, es2020: true },
@@ -15,9 +116,43 @@ module.exports = {
"react/jsx-no-target-blank": "off",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"react/no-unescaped-entities": "off",
+ ...FRONTEND_CONVENTION_RULES,
},
globals: {
__APP_VERSION__: "readonly",
process: "readonly",
},
+ overrides: [
+ {
+ files: ["**/*.ts", "**/*.tsx"],
+ parser: "@typescript-eslint/parser",
+ parserOptions: { ecmaVersion: "latest", sourceType: "module" },
+ extends: [
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:react/recommended",
+ "plugin:react/jsx-runtime",
+ "plugin:react-hooks/recommended",
+ ],
+ plugins: ["@typescript-eslint", "react-refresh"],
+ rules: {
+ "react/jsx-no-target-blank": "off",
+ "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
+ "react/no-unescaped-entities": "off",
+ "no-unused-vars": "off",
+ "@typescript-eslint/no-unused-vars": [
+ "warn",
+ { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
+ ],
+ // TS handles these; eslint duplicates create noise on existing files.
+ "@typescript-eslint/no-explicit-any": "off",
+ "@typescript-eslint/ban-ts-comment": "off",
+ "@typescript-eslint/ban-types": "off",
+ "@typescript-eslint/no-empty-function": "off",
+ "@typescript-eslint/no-empty-interface": "off",
+ "@typescript-eslint/no-non-null-assertion": "off",
+ ...FRONTEND_CONVENTION_RULES,
+ },
+ },
+ ],
};
diff --git a/client/package-lock.json b/client/package-lock.json
index 850a2b149..30368ec25 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -49,6 +49,8 @@
"@types/node": "24.5.2",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
+ "@typescript-eslint/eslint-plugin": "^7.18.0",
+ "@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
@@ -2350,6 +2352,238 @@
"version": "0.0.6",
"license": "MIT"
},
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz",
+ "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.10.0",
+ "@typescript-eslint/scope-manager": "7.18.0",
+ "@typescript-eslint/type-utils": "7.18.0",
+ "@typescript-eslint/utils": "7.18.0",
+ "@typescript-eslint/visitor-keys": "7.18.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.3.1",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^7.0.0",
+ "eslint": "^8.56.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz",
+ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "7.18.0",
+ "@typescript-eslint/types": "7.18.0",
+ "@typescript-eslint/typescript-estree": "7.18.0",
+ "@typescript-eslint/visitor-keys": "7.18.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz",
+ "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "7.18.0",
+ "@typescript-eslint/visitor-keys": "7.18.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz",
+ "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/typescript-estree": "7.18.0",
+ "@typescript-eslint/utils": "7.18.0",
+ "debug": "^4.3.4",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz",
+ "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz",
+ "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/types": "7.18.0",
+ "@typescript-eslint/visitor-keys": "7.18.0",
+ "debug": "^4.3.4",
+ "globby": "^11.1.0",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz",
+ "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.4.0",
+ "@typescript-eslint/scope-manager": "7.18.0",
+ "@typescript-eslint/types": "7.18.0",
+ "@typescript-eslint/typescript-estree": "7.18.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "7.18.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz",
+ "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "7.18.0",
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"dev": true,
@@ -2557,6 +2791,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/array-union": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
+ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/array.prototype.findlast": {
"version": "1.2.5",
"dev": true,
@@ -3272,6 +3516,19 @@
"node": ">=6"
}
},
+ "node_modules/dir-glob": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+ "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/doctrine": {
"version": "3.0.0",
"dev": true,
@@ -3814,6 +4071,36 @@
"node": ">=6.0.0"
}
},
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"dev": true,
@@ -4170,6 +4457,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/globby": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
+ "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-union": "^2.1.0",
+ "dir-glob": "^3.0.1",
+ "fast-glob": "^3.2.9",
+ "ignore": "^5.2.0",
+ "merge2": "^1.4.1",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"license": "MIT",
@@ -5137,6 +5445,16 @@
"node": ">= 0.4"
}
},
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -6393,6 +6711,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/slash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
@@ -6793,6 +7121,19 @@
"node": ">=8.0"
}
},
+ "node_modules/ts-api-utils": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",
+ "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.2.0"
+ }
+ },
"node_modules/tslib": {
"version": "2.8.1",
"license": "0BSD"
diff --git a/client/package.json b/client/package.json
index a09e89c4c..0400b5c00 100644
--- a/client/package.json
+++ b/client/package.json
@@ -7,7 +7,8 @@
"dev": "vite",
"build": "tsc -b && vite build",
"build-dev": "tsc -b && vite build --mode development",
- "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
+ "lint": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives",
+ "lint-strict": "eslint . --ext js,jsx,ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"format": "prettier --write .",
"format-check": "prettier --check ."
@@ -57,6 +58,8 @@
"@types/node": "24.5.2",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
+ "@typescript-eslint/eslint-plugin": "^7.18.0",
+ "@typescript-eslint/parser": "^7.18.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
diff --git a/docs/frontend-conventions.md b/docs/frontend-conventions.md
new file mode 100644
index 000000000..4f14c555b
--- /dev/null
+++ b/docs/frontend-conventions.md
@@ -0,0 +1,211 @@
+# Frontend conventions
+
+Style and structural rules the client codebase follows. These are extracted from review feedback on the PR series #3591–#3596 and are non-negotiable for new code. They exist to keep the codebase greppable, maintainable, and consistent with the existing theme.
+
+> If you're touching a file in `client/src`, every rule below applies. The "why" matters as much as the "what" — read the reasoning so you can judge edge cases.
+
+---
+
+## 1. Prefer MUI native (system) props over `sx`
+
+MUI components accept layout, spacing, color, border, typography and sizing values as top-level props. Use those. Reach for `sx` only when no native prop exists for the value you need (e.g. `textDecoration`, pseudo-selectors, complex selectors, conditional CSS).
+
+**Why:** native props are easier to read in JSX, easier to grep, easier to refactor, and don't force the reader to mentally parse a nested object literal for things that are conceptually one attribute. A `sx` block also can't be split-line-edited cleanly when most of its keys are simple primitives.
+
+**How to apply:** any time you write `sx={{ … }}`, ask whether each key has a native equivalent. If yes, hoist it.
+
+### Canonical mappings
+
+| `sx` key | Native prop |
+|---|---|
+| `color` | `color` |
+| `backgroundColor` | `bgcolor` |
+| `borderRadius` | `borderRadius` |
+| `border`, `borderTop`, … | `border`, `borderTop`, … |
+| `width`, `height`, `minHeight`, `maxWidth` | same names as native |
+| `p`, `pt`, `pb`, `px`, `py` | same names as native |
+| `m`, `mt`, `mb`, `mx`, `my` | same names as native |
+| `display` | `display` |
+| `textAlign` | `textAlign` |
+| `fontWeight`, `fontSize`, `lineHeight` | same names as native |
+| `gap` (on Stack) | `gap` |
+
+### Examples
+
+```tsx
+// BAD — everything inside sx
+
+
+// GOOD — every key that has a native prop is hoisted
+
+```
+
+```tsx
+// BAD
+
+
+// GOOD — hoist what you can; drop fontSize:13 because it's the body default
+
+```
+
+### When `sx` is still correct
+
+Keep `sx` for things that don't have a native prop:
+
+```tsx
+
+```
+
+Pseudo-selectors (`"&:hover"`), `transition`, `cursor`, `outline`, `overflow` (when not on Box/Stack), media queries, and conditional spread objects all stay in `sx`.
+
+---
+
+## 2. Use the full theme path for colors, never the shorthand string
+
+Prop forms like `color="text.secondary"`, `color="primary.main"`, `bgcolor="background.paper"` work, but they are **forbidden in this codebase**.
+
+**Why:** the shorthand is invisible to grep. If a future change needs to find every place that consumes `theme.palette.text.secondary` — to retheme, to swap, to audit — the shorthand strings are missed. That is a maintenance trap. It's worth a few extra characters at every callsite to keep the codebase searchable.
+
+**How to apply:** always destructure or reference the theme value explicitly.
+
+```tsx
+// BAD
+…
+…
+
+// GOOD
+const theme = useTheme();
+…
+…
+```
+
+This applies to every theme-aware string prop (`color`, `bgcolor`, `borderColor`, etc.), and inside `sx` if you really need it there.
+
+---
+
+## 3. No hardcoded numeric or string literals — use tokens
+
+The theme exposes tokens for every kind of magic value. Use them. Reserve raw numbers for values that are genuinely one-off (and even then, push back on yourself first).
+
+**Why:** tokens are the single source of truth. A literal `2` in a `mt` prop is invisible — it could mean `LAYOUT.XXS`, a rounding correction, or a thinko. The reader can't tell. With a token, intent is in the name.
+
+**How to apply:** before writing a literal, check whether one of these tokens covers it. If it nearly does but not exactly, extend the token rather than hardcoding (Alex did exactly this — he added `LAYOUT.XXS = 2` rather than letting `mt: "2px"` stand).
+
+### Token sources
+
+- **Spacing / layout** — `client/src/Utils/Theme/constants.ts`
+ - `LAYOUT.XXS = 2` · `LAYOUT.XS = 4` · `LAYOUT.SM = 6` · `LAYOUT.MD = 8` · `LAYOUT.LG = 10` · `LAYOUT.XL = 12` · `LAYOUT.XXL = 16`
+ - `SPACING` for `theme.spacing(...)` multipliers
+- **Typography sizes** — `client/src/Utils/Theme/Palette.ts` → `typographyLevels`
+ - `xs / s / m / l / xl / xxl` (rem values)
+- **Hover / interaction** — `HOVER.DARKEN` etc.
+- **Shape** — `theme.shape.borderRadius` (don't write `borderRadius: 1` or `borderRadius: 4`)
+- **Colors** — `theme.palette.*` (rule 2 above)
+
+### Examples
+
+```tsx
+// BAD
+
+
+
+
+ // 13 is the body default — drop the prop entirely
+
+// GOOD
+
+
+
+
+ // inherits 13px from theme
+```
+
+### Token naming gotcha
+
+`typographyLevels.xl` was historically used for both 18px and 23px callsites. Don't reuse a token for two different purposes — split it (`xl` = 18, `xxl` = 23) and update every callsite. If you find an ambiguous token while editing, fix it the same way.
+
+---
+
+## 4. Use `useTheme()` inside components, don't import the theme module
+
+The `theme` object exported from `@/Utils/Theme/Theme` is the *factory output*, not a live, mode-aware instance. Importing it directly couples a component to the wrong reference and breaks dark-mode reactivity. It also breaks the build at import time in some module-graph configurations (this happened in PR #3596 and required a follow-up commit).
+
+**Why:** themes can change at runtime (mode toggle), and the hook is the only correct way to read the active theme. Imports also create circular-graph risk because `Theme.ts` itself depends on `Palette.ts`.
+
+**How to apply:** inside any component or component-helper that needs theme values:
+
+```tsx
+// BAD
+import { theme } from "@/Utils/Theme/Theme";
+const SectionHeading = ({ children }) => (
+ {children}
+);
+
+// GOOD
+import { useTheme } from "@mui/material/styles";
+const SectionHeading = ({ children }) => {
+ const theme = useTheme();
+ return {children} ;
+};
+```
+
+Module-level utility files that don't render React (pure helpers) should accept `theme` as a parameter rather than importing it.
+
+---
+
+## 5. Discriminated unions: pair the runtime list with the type
+
+When you have a closed set of string literals that's used both as a type and as runtime data (severities, statuses, kinds), declare the runtime tuple first and derive the type from it. Don't declare them as two parallel literals that can drift.
+
+**Why:** if the list and the type aren't tied to each other, adding a new value to one and forgetting the other compiles fine and breaks at runtime. Deriving the type from the tuple makes them inseparable.
+
+```tsx
+// BAD
+type Severity = "info" | "warning" | "error";
+
+// GOOD
+export const Severities = ["info", "warning", "error"] as const;
+export type Severity = (typeof Severities)[number];
+```
+
+Export both when callers need to iterate (e.g. for a Storybook stories matrix or a select dropdown).
+
+---
+
+## Pre-PR checklist
+
+Before you open a PR, scan your diff for these:
+
+- [ ] Any `sx={{ … }}` block that contains keys with a native-prop equivalent (rule 1)
+- [ ] Any `color="text.X"`, `bgcolor="background.X"`, or any short-string palette path (rule 2)
+- [ ] Any raw number or px-string in spacing / margin / padding / fontSize / borderRadius props that should be a token (rule 3)
+- [ ] Any `import { theme } from "@/Utils/Theme/Theme"` inside a component (rule 4)
+- [ ] Any `type X = "a" | "b" | "c"` for a closed set used at runtime (rule 5)
+- [ ] Run `npm run build` and `npm run format-check` in both `client/` and `server/` (per global instructions)
+
+A clean PR on these axes is a PR that ships without a normalize follow-up.
From 45c02e0f7ff9e480aec578c545518cec538c652c Mon Sep 17 00:00:00 2001
From: Alex Holliday
Date: Wed, 29 Apr 2026 10:51:40 -0700
Subject: [PATCH 14/19] unsafe lookup fix
---
client/src/Pages/StatusPage/Status/index.tsx | 14 +++++++-------
.../Status/themes/StatusPageThemeProvider.tsx | 11 ++++++-----
client/src/Types/StatusPage.ts | 10 ++++++++++
3 files changed, 23 insertions(+), 12 deletions(-)
diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx
index a4864d748..86b9d90ad 100644
--- a/client/src/Pages/StatusPage/Status/index.tsx
+++ b/client/src/Pages/StatusPage/Status/index.tsx
@@ -13,6 +13,7 @@ import { useLocation, useParams } from "react-router-dom";
import { useGet } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { StatusPage, StatusPageResponse, StatusPageTheme } from "@/Types/StatusPage";
+import { resolveStatusPageTheme } from "@/Types/StatusPage";
import { HeaderStatusPageControls } from "./Components/HeaderStatusPageControls";
import { StatusPageThemeProvider } from "./themes/StatusPageThemeProvider";
import { RefinedStatusPage } from "./themes/refined/RefinedStatusPage";
@@ -26,8 +27,9 @@ type ThemedRendererProps = {
monitors: (Monitor & { checks?: Monitor["recentChecks"] })[];
};
-const THEMED_RENDERERS: Partial<
- Record>
+const THEMED_RENDERERS: Record<
+ StatusPageTheme,
+ React.ComponentType
> = {
refined: RefinedStatusPage,
modern: ModernStatusPage,
@@ -97,15 +99,13 @@ const StatusPageView = () => {
? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
: null;
- const ThemedRenderer = statusPage.theme
- ? THEMED_RENDERERS[statusPage.theme]
- : undefined;
- const themedRenderer = ThemedRenderer ? (
+ const ThemedRenderer = THEMED_RENDERERS[resolveStatusPageTheme(statusPage.theme)];
+ const themedRenderer = (
- ) : null;
+ );
if (themedRenderer) {
// Public route: render directly on the viewport, themed background covers everything.
diff --git a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx
index 294198330..416ee3293 100644
--- a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx
@@ -8,7 +8,8 @@ import {
} from "react";
import {
DEFAULT_STATUS_PAGE_THEME,
- DEFAULT_STATUS_PAGE_THEME_MODE,
+ resolveStatusPageTheme,
+ resolveStatusPageThemeMode,
type StatusPageTheme,
type StatusPageThemeMode,
} from "@/Types/StatusPage";
@@ -58,8 +59,8 @@ export const StatusPageThemeProvider = ({
transparent = false,
children,
}: Props) => {
- const resolvedTheme = theme ?? DEFAULT_STATUS_PAGE_THEME;
- const resolvedThemeMode = themeMode ?? DEFAULT_STATUS_PAGE_THEME_MODE;
+ const resolvedTheme = resolveStatusPageTheme(theme);
+ const resolvedThemeMode = resolveStatusPageThemeMode(themeMode);
const [systemMode, setSystemMode] = useState(resolveSystemMode);
@@ -89,7 +90,7 @@ export const StatusPageThemeProvider = ({
htmlColorScheme: html.style.colorScheme,
};
const resolvedMode = resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode;
- const bg = themeTokens[theme ?? DEFAULT_STATUS_PAGE_THEME][resolvedMode].bg;
+ const bg = themeTokens[resolvedTheme][resolvedMode].bg;
html.style.background = bg;
body.style.background = bg;
body.style.margin = "0";
@@ -100,7 +101,7 @@ export const StatusPageThemeProvider = ({
body.style.margin = prev.bodyMargin;
html.style.colorScheme = prev.htmlColorScheme;
};
- }, [paintBody, theme, resolvedThemeMode, systemMode]);
+ }, [paintBody, resolvedTheme, resolvedThemeMode, systemMode]);
const resolvedMode: ResolvedMode =
resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode;
diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts
index ed592fc07..22b215ced 100644
--- a/client/src/Types/StatusPage.ts
+++ b/client/src/Types/StatusPage.ts
@@ -29,6 +29,16 @@ export const STATUS_PAGE_THEME_MODES = ["auto", "light", "dark"] as const;
export type StatusPageThemeMode = (typeof STATUS_PAGE_THEME_MODES)[number];
export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto";
+export const resolveStatusPageTheme = (
+ value: string | null | undefined
+): StatusPageTheme =>
+ STATUS_PAGE_THEMES.find((t) => t === value) ?? DEFAULT_STATUS_PAGE_THEME;
+
+export const resolveStatusPageThemeMode = (
+ value: string | null | undefined
+): StatusPageThemeMode =>
+ STATUS_PAGE_THEME_MODES.find((m) => m === value) ?? DEFAULT_STATUS_PAGE_THEME_MODE;
+
export interface StatusPage {
id: string;
userId: string;
From edeed00be64a4b0c79150aa576cdee3dd15e40e5 Mon Sep 17 00:00:00 2001
From: Alex Holliday
Date: Wed, 29 Apr 2026 10:56:57 -0700
Subject: [PATCH 15/19] fix unnecessary cast, remove dead keys
---
.../Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx | 2 +-
.../Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx | 2 +-
client/src/locales/en.json | 3 ---
3 files changed, 2 insertions(+), 5 deletions(-)
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
index 30510611b..44a93e14b 100644
--- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
@@ -64,7 +64,7 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
variant="caption"
sx={{ opacity: 0.8 }}
>
- {formatCheckTimestamp(check.createdAt as unknown as string)}
+ {formatCheckTimestamp(check.createdAt)}
);
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
index 8af4ffebf..9139b3940 100644
--- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
@@ -77,7 +77,7 @@ export const ThemedHistogram = ({
variant="caption"
sx={{ opacity: 0.8 }}
>
- {formatCheckTimestamp(check.createdAt as unknown as string)}
+ {formatCheckTimestamp(check.createdAt)}
);
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index 96aaa4045..d425322bf 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -1345,8 +1345,6 @@
"title": "Page theme",
"description": "Choose the visual style for your public status page.",
"label": "Theme",
- "previewLight": "Light",
- "previewDark": "Dark",
"options": {
"refined": {
"name": "Refined",
@@ -1368,7 +1366,6 @@
},
"themeMode": {
"label": "Theme mode",
- "description": "Auto follows the visitor's device preference.",
"auto": "Auto (follow visitor's device)",
"light": "Light only",
"dark": "Dark only"
From d32ea7f8c3cc1263d112ca64a16e0a9798e83a15 Mon Sep 17 00:00:00 2001
From: Alex Holliday
Date: Wed, 29 Apr 2026 11:07:34 -0700
Subject: [PATCH 16/19] remove dead code
---
.../Components/InfrastructureMetrics.tsx | 246 ------------------
.../Status/Components/MonitorsList.tsx | 174 -------------
.../Status/Components/StatusBar.tsx | 173 ------------
client/src/Pages/StatusPage/Status/index.tsx | 112 ++------
4 files changed, 28 insertions(+), 677 deletions(-)
delete mode 100644 client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx
delete mode 100644 client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx
delete mode 100644 client/src/Pages/StatusPage/Status/Components/StatusBar.tsx
diff --git a/client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx b/client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx
deleted file mode 100644
index 32a15d64b..000000000
--- a/client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx
+++ /dev/null
@@ -1,246 +0,0 @@
-import Stack from "@mui/material/Stack";
-import Typography from "@mui/material/Typography";
-import { Gauge } from "@/Components/design-elements";
-import { useTheme } from "@mui/material/styles";
-import { useTranslation } from "react-i18next";
-import prettyBytes from "pretty-bytes";
-import type { Monitor } from "@/Types/Monitor";
-import Grid from "@mui/material/Grid";
-import useMediaQuery from "@mui/material/useMediaQuery";
-import Box from "@mui/material/Box";
-import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
-
-const GAUGE_RADIUS = 60;
-const GAUGE_STROKE_WIDTH = 12;
-const PERCENTAGE_MULTIPLIER = 100;
-
-interface StatusPageMonitor extends Monitor {
- checks?: Monitor["recentChecks"];
-}
-
-interface MetricDetail {
- label: string;
- value: string;
-}
-
-interface MetricConfig {
- key: string;
- label: string;
- hasData: boolean;
- progress: number;
- details: MetricDetail[];
-}
-
-const MetricDetailRow = ({ label, value }: MetricDetail) => {
- const theme = useTheme();
- return (
-
-
- {label}
-
- {value}
-
- );
-};
-
-interface MetricItemProps {
- label: string;
- progress: number;
- details?: MetricDetail[];
-}
-
-const MetricItem = ({ label, progress, details }: MetricItemProps) => {
- const theme = useTheme();
- const isSmall = useMediaQuery(theme.breakpoints.down("md"));
- return (
-
-
-
- {label}
-
- {details && details.length > 0 && (
-
- {details.map((detail) => (
-
- ))}
-
- )}
-
- );
-};
-
-type LatestCheck = NonNullable[number];
-
-const buildCpuMetric = (
- check: LatestCheck,
- t: (key: string) => string
-): MetricConfig | null => {
- if (!check.cpu || typeof check.cpu.usage_percent !== "number") {
- return null;
- }
- const usagePercent = (check.cpu.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER;
- return {
- key: "cpu",
- label: t("pages.statusPages.monitorsList.infrastructure.cpu"),
- hasData: true,
- progress: usagePercent,
- details: [
- {
- label: t("pages.statusPages.monitorsList.infrastructure.usage"),
- value: `${usagePercent.toFixed(2)}%`,
- },
- ],
- };
-};
-
-const buildMemoryMetric = (
- check: LatestCheck,
- t: (key: string) => string
-): MetricConfig | null => {
- if (
- !check.memory ||
- typeof check.memory.usage_percent !== "number" ||
- typeof check.memory.used_bytes !== "number" ||
- typeof check.memory.total_bytes !== "number"
- ) {
- return null;
- }
- return {
- key: "memory",
- label: t("pages.statusPages.monitorsList.infrastructure.memory"),
- hasData: true,
- progress: (check.memory.usage_percent ?? 0) * PERCENTAGE_MULTIPLIER,
- details: [
- {
- label: t("pages.statusPages.monitorsList.infrastructure.used"),
- value: prettyBytes(check.memory.used_bytes ?? 0),
- },
- {
- label: t("pages.statusPages.monitorsList.infrastructure.total"),
- value: prettyBytes(check.memory.total_bytes ?? 0),
- },
- ],
- };
-};
-
-const buildDiskMetric = (
- check: LatestCheck,
- t: (key: string) => string
-): MetricConfig | null => {
- if (!check.disk || check.disk.length === 0) {
- return null;
- }
- const disks = check.disk;
- const avgUsagePercent =
- (disks.reduce((acc, disk) => acc + (disk?.usage_percent ?? 0), 0) / disks.length) *
- PERCENTAGE_MULTIPLIER;
- const totalUsedBytes = disks.reduce((acc, disk) => acc + (disk?.used_bytes ?? 0), 0);
- const totalTotalBytes = disks.reduce((acc, disk) => acc + (disk?.total_bytes ?? 0), 0);
-
- return {
- key: "disk",
- label: t("pages.statusPages.monitorsList.infrastructure.disk"),
- hasData: true,
- progress: avgUsagePercent,
- details: [
- {
- label: t("pages.statusPages.monitorsList.infrastructure.used"),
- value: prettyBytes(totalUsedBytes),
- },
- {
- label: t("pages.statusPages.monitorsList.infrastructure.total"),
- value: prettyBytes(totalTotalBytes),
- },
- ],
- };
-};
-
-export const InfrastructureMetrics = ({ monitor }: { monitor: StatusPageMonitor }) => {
- const theme = useTheme();
- const { t } = useTranslation();
-
- const latestCheck = monitor.recentChecks?.[0] ?? monitor.checks?.[0];
-
- if (!latestCheck) {
- return (
-
- {t("pages.statusPages.monitorsList.noData")}
-
- );
- }
-
- const metrics: MetricConfig[] = [
- buildCpuMetric(latestCheck, t),
- buildMemoryMetric(latestCheck, t),
- buildDiskMetric(latestCheck, t),
- ].filter((m): m is MetricConfig => m !== null);
-
- if (metrics.length === 0) {
- return (
-
- {t("pages.statusPages.monitorsList.noData")}
-
- );
- }
-
- return (
-
- {metrics.map(({ key, label, progress, details }) => (
-
- ))}
-
- );
-};
diff --git a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx b/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx
deleted file mode 100644
index 01e7d1975..000000000
--- a/client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx
+++ /dev/null
@@ -1,174 +0,0 @@
-import Stack from "@mui/material/Stack";
-import { useTranslation } from "react-i18next";
-import Box from "@mui/material/Box";
-import Typography from "@mui/material/Typography";
-import { HistogramResponseTime, HeatmapResponseTime } from "@/Components/common";
-import { StatusLabel, BaseBox } from "@/Components/design-elements";
-import { SwitchComponent } from "@/Components/inputs";
-import { InfrastructureMetrics } from "@/Pages/StatusPage/Status/Components/InfrastructureMetrics";
-
-import { useTheme, type Theme } from "@mui/material/styles";
-import { useSelector } from "react-redux";
-import { useState } from "react";
-import type { Monitor } from "@/Types/Monitor";
-import type { StatusPage } from "@/Types/StatusPage";
-import { getMonitorTypeLabel } from "@/Types/StatusPage";
-import type { RootState } from "@/Types/state";
-import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
-
-interface StatusPageMonitor extends Monitor {
- checks?: Monitor["recentChecks"];
-}
-
-interface MonitorsListProps {
- statusPage: StatusPage;
- monitors: StatusPageMonitor[];
-}
-
-const getMonitorBadgeStyles = (monitorType: string, theme: Theme) => {
- const bg =
- monitorType === "hardware" ? theme.palette.info.light : theme.palette.success.light;
- return {
- backgroundColor: bg,
- color: theme.palette.background.paper,
- padding: `${theme.spacing(SPACING.SM)} ${theme.spacing(SPACING.LG)}`,
- borderRadius: theme.shape.borderRadius,
- };
-};
-
-const MonitorHeader = ({
- monitor,
- showURL,
-}: {
- monitor: StatusPageMonitor;
- showURL: boolean;
-}) => {
- const theme = useTheme();
- const { t } = useTranslation();
-
- return (
-
-
-
-
- {monitor.name}
-
-
- {getMonitorTypeLabel(monitor.type, t)}
-
-
- {showURL && monitor.url && (
-
- {monitor.url}
-
- )}
-
-
-
- );
-};
-
-const MonitorContent = ({
- monitor,
- statusPage,
- chartType,
-}: {
- monitor: StatusPageMonitor;
- statusPage: StatusPage;
- chartType: string;
-}) => {
- const theme = useTheme();
-
- if (monitor.type === "hardware") {
- if (statusPage.showInfrastructure === false) return null;
- return ;
- }
-
- if (statusPage.showCharts === false) return null;
-
- const checks = monitor.checks?.slice().reverse() ?? [];
- return (
-
- {chartType === "histogram" ? (
-
- ) : (
-
- )}
-
- );
-};
-
-export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
- const theme = useTheme();
- const { t } = useTranslation();
- const showURL = useSelector((state: RootState) => state.ui?.showURL);
- const [chartType, setChartType] = useState<"histogram" | "heatmap">("histogram");
-
- return (
-
- {statusPage.showCharts && (
-
- {t("pages.statusPages.monitorsList.chartTypeHeatmap")}
- setChartType(e.target.checked ? "histogram" : "heatmap")}
- />
-
- {t("pages.statusPages.monitorsList.chartTypeHistogram")}
-
-
- )}
-
- {monitors.map((monitor) => (
-
-
-
-
- ))}
-
- );
-};
diff --git a/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx b/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx
deleted file mode 100644
index 49f70d952..000000000
--- a/client/src/Pages/StatusPage/Status/Components/StatusBar.tsx
+++ /dev/null
@@ -1,173 +0,0 @@
-import {
- AlertTriangle,
- CircleCheck,
- CircleX,
- Loader,
- PauseCircle,
- ShieldAlert,
- Wrench,
-} from "lucide-react";
-import Stack from "@mui/material/Stack";
-import Typography from "@mui/material/Typography";
-
-import { useTranslation } from "react-i18next";
-import { useTheme } from "@mui/material";
-import type { Monitor, MonitorStatus } from "@/Types/Monitor";
-
-type SemanticTone = "up" | "down" | "warn" | "info";
-
-interface StatusDisplay {
- icon: JSX.Element;
- msg?: string;
- tone: SemanticTone;
-}
-
-const getMonitorStatus = (monitors: Monitor[], t: Function): StatusDisplay => {
- if (monitors.length === 0) {
- return {
- msg: t("pages.statusPages.statusBar.noMonitors"),
- tone: "warn",
- icon: ,
- };
- }
-
- const allOf = (...statuses: MonitorStatus[]) =>
- monitors.every((m) => statuses.includes(m.status));
- const someOf = (...statuses: MonitorStatus[]) =>
- monitors.some((m) => statuses.includes(m.status));
- const noneOf = (...statuses: MonitorStatus[]) =>
- monitors.every((m) => !statuses.includes(m.status));
-
- if (allOf("up")) {
- return {
- msg: t("pages.statusPages.statusBar.allUp"),
- tone: "up",
- icon: ,
- };
- }
- if (allOf("breached")) {
- return {
- msg: t("pages.statusPages.statusBar.allBreached"),
- tone: "down",
- icon: ,
- };
- }
- if (allOf("maintenance")) {
- return {
- msg: t("pages.statusPages.statusBar.allMaintenance"),
- tone: "warn",
- icon: ,
- };
- }
- if (allOf("down")) {
- return {
- msg: t("pages.statusPages.statusBar.allDown"),
- tone: "down",
- icon: ,
- };
- }
- if (allOf("paused")) {
- return {
- msg: t("pages.statusPages.statusBar.allPaused"),
- tone: "warn",
- icon: ,
- };
- }
- if (allOf("initializing")) {
- return {
- msg: t("pages.statusPages.statusBar.allInitializing"),
- tone: "warn",
- icon: ,
- };
- }
-
- // Breached takes highest priority in mixed states
- if (someOf("breached") && someOf("down")) {
- return {
- msg: t("pages.statusPages.statusBar.breachedAndDown"),
- tone: "down",
- icon: ,
- };
- }
- if (someOf("breached")) {
- return {
- msg: t("pages.statusPages.statusBar.breached"),
- tone: "down",
- icon: ,
- };
- }
- if (someOf("maintenance") && someOf("down")) {
- return {
- msg: t("pages.statusPages.statusBar.maintenanceAndDown"),
- tone: "down",
- icon: ,
- };
- }
- if (someOf("maintenance") && noneOf("down")) {
- return {
- msg: t("pages.statusPages.statusBar.maintenance"),
- tone: "warn",
- icon: ,
- };
- }
- if (someOf("down")) {
- return {
- msg: t("pages.statusPages.statusBar.degraded"),
- tone: "warn",
- icon: ,
- };
- }
- if (someOf("paused")) {
- return {
- msg: t("pages.statusPages.statusBar.partiallyPaused"),
- tone: "warn",
- icon: ,
- };
- }
- if (someOf("initializing")) {
- return {
- msg: t("pages.statusPages.statusBar.initializing"),
- tone: "info",
- icon: ,
- };
- }
-
- return {
- msg: t("pages.statusPages.statusBar.unknown"),
- tone: "warn",
- icon: ,
- };
-};
-
-const toneToCssVar: Record = {
- up: "var(--sp-up)",
- down: "var(--sp-down)",
- warn: "var(--sp-warn)",
- info: "var(--sp-up)",
-};
-
-interface StatusBarProps {
- monitors: Monitor[];
-}
-
-export const StatusBar = ({ monitors }: StatusBarProps) => {
- const theme = useTheme();
- const { t } = useTranslation();
- const monitorsStatus = getMonitorStatus(monitors, t);
-
- return (
-
- {monitorsStatus.icon}
- {monitorsStatus.msg}
-
- );
-};
diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx
index 86b9d90ad..1f9cca9ee 100644
--- a/client/src/Pages/StatusPage/Status/index.tsx
+++ b/client/src/Pages/StatusPage/Status/index.tsx
@@ -1,12 +1,9 @@
import { BasePage, BaseFallback } from "@/Components/design-elements";
-import { StatusBar } from "@/Pages/StatusPage/Status/Components/StatusBar";
-import { MonitorsList } from "@/Pages/StatusPage/Status/Components/MonitorsList";
import Typography from "@mui/material/Typography";
import { Link } from "react-router-dom";
import Stack from "@mui/material/Stack";
-import Box from "@mui/material/Box";
-import { useMediaQuery, useTheme } from "@mui/material";
+import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useLocation, useParams } from "react-router-dom";
@@ -43,7 +40,6 @@ const StatusPageView = () => {
const { url } = useParams();
const isAdmin = useIsAdmin();
const location = useLocation();
- const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isPublic = location.pathname.startsWith("/status/public");
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
@@ -88,17 +84,6 @@ const StatusPageView = () => {
);
}
- let sx: React.CSSProperties = {};
- if (isPublic) {
- sx.paddingTop = theme.spacing(20);
- sx.paddingLeft = isSmall ? "5vw" : "20vw";
- sx.paddingRight = isSmall ? "5vw" : "20vw";
- }
-
- const logoSrc = statusPage.logo?.data
- ? `data:${statusPage.logo.contentType};base64,${statusPage.logo.data}`
- : null;
-
const ThemedRenderer = THEMED_RENDERERS[resolveStatusPageTheme(statusPage.theme)];
const themedRenderer = (
{
/>
);
- if (themedRenderer) {
- // Public route: render directly on the viewport, themed background covers everything.
- if (isPublic) {
- return (
-
- {themedRenderer}
-
- );
- }
-
- // Admin preview: wrap in a mock browser frame inside the admin shell.
- const publicUrl = `${window.location.origin}/status/public/${statusPage.url}`;
+ // Public route: render directly on the viewport, themed background covers everything.
+ if (isPublic) {
return (
-
-
-
- {themedRenderer}
-
-
+ {themedRenderer}
+
);
}
+ // Admin preview: wrap in a mock browser frame inside the admin shell.
+ const publicUrl = `${window.location.origin}/status/public/${statusPage.url}`;
return (
-
-
+
-
- {logoSrc && (
-
- )}
-
-
-
-
+ {themedRenderer}
+
+
);
};
From 1398ab672122d460cd5d74c63626b63acd69a2d6 Mon Sep 17 00:00:00 2001
From: Alex Holliday
Date: Wed, 29 Apr 2026 11:12:35 -0700
Subject: [PATCH 17/19] remove dead code
---
.../Components/HeaderStatusPageControls.tsx | 1 -
.../StatusPage/Status/themes/BrowserFrame.tsx | 26 ++-
.../Status/themes/StatusPageThemeProvider.tsx | 11 +-
.../Status/themes/theme-overrides.css | 156 ------------------
.../Pages/StatusPage/Status/themes/tokens.ts | 19 ---
5 files changed, 12 insertions(+), 201 deletions(-)
delete mode 100644 client/src/Pages/StatusPage/Status/themes/theme-overrides.css
diff --git a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx
index 1371fa722..2cc2395bb 100644
--- a/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx
+++ b/client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx
@@ -37,7 +37,6 @@ export const HeaderStatusPageControls = ({
>
{
- const theme = useTheme();
+ const { tokens } = useStatusPageTheme();
return (
{
flexDirection: "column",
borderRadius: "12px",
overflow: "hidden",
- border: "1px solid var(--sp-border, " + theme.palette.divider + ")",
- background: "var(--sp-surface, " + theme.palette.background.paper + ")",
- color: "var(--sp-text, " + theme.palette.text.primary + ")",
+ border: `1px solid ${tokens.border}`,
+ background: tokens.surface,
+ color: tokens.text,
}}
>
{
alignItems: "center",
gap: 1.25,
padding: "10px 14px",
- background:
- "var(--sp-bg, " +
- (theme.palette.mode === "dark" ? "#1a1a1a" : "#f1f2f4") +
- ")",
- borderBottom: "1px solid var(--sp-border, " + theme.palette.divider + ")",
+ background: tokens.bg,
+ borderBottom: `1px solid ${tokens.border}`,
}}
>
@@ -54,14 +51,11 @@ export const BrowserFrame = ({ url, children }: Props) => {
padding: "4px 12px",
marginLeft: 2,
borderRadius: "6px",
- background:
- "var(--sp-surface, " +
- (theme.palette.mode === "dark" ? "#2a2a2a" : "#ffffff") +
- ")",
- border: "1px solid var(--sp-border, " + theme.palette.divider + ")",
+ background: tokens.surface,
+ border: `1px solid ${tokens.border}`,
fontFamily: "ui-monospace, Menlo, monospace",
fontSize: 12,
- color: "var(--sp-text-muted, " + theme.palette.text.secondary + ")",
+ color: tokens.textMuted,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
diff --git a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx
index 416ee3293..2b10b0d9a 100644
--- a/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx
@@ -13,8 +13,7 @@ import {
type StatusPageTheme,
type StatusPageThemeMode,
} from "@/Types/StatusPage";
-import { themeTokens, toCssVars, type StatusPageThemeTokens } from "./tokens";
-import "./theme-overrides.css";
+import { themeTokens, type StatusPageThemeTokens } from "./tokens";
type ResolvedMode = "light" | "dark";
@@ -107,8 +106,6 @@ export const StatusPageThemeProvider = ({
resolvedThemeMode === "auto" ? systemMode : resolvedThemeMode;
const tokens = themeTokens[resolvedTheme][resolvedMode];
- const style = useMemo(() => toCssVars(tokens) as React.CSSProperties, [tokens]);
-
const value = useMemo(
() => ({ theme: resolvedTheme, mode: resolvedMode, tokens }),
[resolvedTheme, resolvedMode, tokens]
@@ -117,14 +114,10 @@ export const StatusPageThemeProvider = ({
return (
= {
bold,
editorial,
};
-
-export const toCssVars = (tokens: StatusPageThemeTokens): Record
=> ({
- "--sp-bg": tokens.bg,
- "--sp-surface": tokens.surface,
- "--sp-border": tokens.border,
- "--sp-text": tokens.text,
- "--sp-text-muted": tokens.textMuted,
- "--sp-up": tokens.up,
- "--sp-up-strong": tokens.upStrong,
- "--sp-up-soft": tokens.upSoft,
- "--sp-degraded": tokens.degraded,
- "--sp-degraded-soft": tokens.degradedSoft,
- "--sp-down": tokens.down,
- "--sp-down-soft": tokens.downSoft,
- "--sp-warn": tokens.warn,
- "--sp-warn-soft": tokens.warnSoft,
- "--sp-radius": tokens.radius,
- ...(tokens.chartBarRadius ? { "--sp-chart-bar-radius": tokens.chartBarRadius } : {}),
-});
From e85e9dc2d405ba1c6c105849bf347e2fb72dda64 Mon Sep 17 00:00:00 2001
From: Alex Holliday
Date: Wed, 29 Apr 2026 11:15:33 -0700
Subject: [PATCH 18/19] remove duplicated time util
---
.../Status/themes/shared/ThemedHeatmap.tsx | 11 +++++++++--
.../Status/themes/shared/ThemedHistogram.tsx | 11 +++++++++--
.../StatusPage/Status/themes/shared/timeFormat.ts | 12 ------------
3 files changed, 18 insertions(+), 16 deletions(-)
delete mode 100644 client/src/Pages/StatusPage/Status/themes/shared/timeFormat.ts
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
index 44a93e14b..8e34dff36 100644
--- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx
@@ -4,8 +4,10 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
+import { useSelector } from "react-redux";
+import type { RootState } from "@/store";
import type { CheckSnapshot } from "@/Types/Check";
-import { formatCheckTimestamp } from "./timeFormat";
+import { formatDateWithTz } from "@/Utils/TimeUtils";
const CELLS = 25;
@@ -27,6 +29,7 @@ const classify = (check: CheckSnapshot): Exclude => {
export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
const { t } = useTranslation();
+ const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const source = checks.slice(0, CELLS).reverse();
const padded: (CheckSnapshot | null)[] = [
@@ -64,7 +67,11 @@ export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => {
variant="caption"
sx={{ opacity: 0.8 }}
>
- {formatCheckTimestamp(check.createdAt)}
+ {formatDateWithTz(
+ check.createdAt,
+ "ddd, MMMM D, YYYY, HH:mm A",
+ uiTimezone
+ )}
);
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
index 9139b3940..54036ebfd 100644
--- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx
@@ -5,8 +5,10 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles";
import { useTranslation } from "react-i18next";
+import { useSelector } from "react-redux";
+import type { RootState } from "@/store";
import type { CheckSnapshot } from "@/Types/Check";
-import { formatCheckTimestamp } from "./timeFormat";
+import { formatDateWithTz } from "@/Utils/TimeUtils";
const CELLS = 25;
const MIN_HEIGHT_PCT = 6;
@@ -32,6 +34,7 @@ export const ThemedHistogram = ({
statsGap = 1,
}: Props) => {
const { t } = useTranslation();
+ const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
const { padded, max, avg, peak } = useMemo(() => {
const source = checks.slice(0, CELLS).reverse();
@@ -77,7 +80,11 @@ export const ThemedHistogram = ({
variant="caption"
sx={{ opacity: 0.8 }}
>
- {formatCheckTimestamp(check.createdAt)}
+ {formatDateWithTz(
+ check.createdAt,
+ "ddd, MMMM D, YYYY, HH:mm A",
+ uiTimezone
+ )}
);
diff --git a/client/src/Pages/StatusPage/Status/themes/shared/timeFormat.ts b/client/src/Pages/StatusPage/Status/themes/shared/timeFormat.ts
deleted file mode 100644
index 7e313471c..000000000
--- a/client/src/Pages/StatusPage/Status/themes/shared/timeFormat.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-export const formatCheckTimestamp = (iso?: string): string => {
- if (!iso) return "—";
- const d = new Date(iso);
- if (Number.isNaN(d.getTime())) return "—";
- return d.toLocaleString(undefined, {
- hour: "2-digit",
- minute: "2-digit",
- second: "2-digit",
- month: "short",
- day: "numeric",
- });
-};
From cc84c37a2d2f82bb054a0405240a5e1af5112079 Mon Sep 17 00:00:00 2001
From: Alex Holliday
Date: Wed, 29 Apr 2026 11:22:20 -0700
Subject: [PATCH 19/19] remove planning docs
---
.../themes/editorial/EditorialStatusPage.tsx | 4 +-
.../Status/themes/editorial/styles.ts | 6 +-
.../themes/refined/RefinedStatusPage.tsx | 4 +-
.../Status/themes/refined/styles.ts | 6 +-
docs/plans/status-page-themes.md | 304 ------------------
5 files changed, 6 insertions(+), 318 deletions(-)
delete mode 100644 docs/plans/status-page-themes.md
diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx
index 7b5ed16a5..ee8fa6f99 100644
--- a/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/editorial/EditorialStatusPage.tsx
@@ -25,8 +25,8 @@ interface Props {
export const EditorialStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
- const { tokens, mode } = useStatusPageTheme();
- const styles = useMemo(() => editorialStyles(tokens, mode === "dark"), [tokens, mode]);
+ const { tokens } = useStatusPageTheme();
+ const styles = useMemo(() => editorialStyles(tokens), [tokens]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const todayLabel = useMemo(
diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts
index 233852875..bed3e72aa 100644
--- a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts
+++ b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts
@@ -50,11 +50,7 @@ export interface EditorialStyles {
footer: SxProps;
}
-export const editorialStyles = (
- tokens: StatusPageThemeTokens,
- isDark: boolean
-): EditorialStyles => {
- void isDark;
+export const editorialStyles = (tokens: StatusPageThemeTokens): EditorialStyles => {
const heatCellBg: Record = {
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`,
diff --git a/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx
index 332e263bc..5c5055b4c 100644
--- a/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx
+++ b/client/src/Pages/StatusPage/Status/themes/refined/RefinedStatusPage.tsx
@@ -26,8 +26,8 @@ interface Props {
export const RefinedStatusPage = ({ statusPage, monitors }: Props) => {
const { t } = useTranslation();
- const { tokens, mode } = useStatusPageTheme();
- const styles = useMemo(() => refinedStyles(tokens, mode === "dark"), [tokens, mode]);
+ const { tokens } = useStatusPageTheme();
+ const styles = useMemo(() => refinedStyles(tokens), [tokens]);
const [chartMode, setChartMode] = useState<"heatmap" | "histogram">("heatmap");
const overall = resolveOverallStatus(monitors, t);
diff --git a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts
index 34d82a171..52626b20c 100644
--- a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts
+++ b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts
@@ -62,11 +62,7 @@ const pillBase = {
fontWeight: 600,
};
-export const refinedStyles = (
- tokens: StatusPageThemeTokens,
- isDark: boolean
-): RefinedStyles => {
- void isDark;
+export const refinedStyles = (tokens: StatusPageThemeTokens): RefinedStyles => {
const heatCellBg: Record = {
fast: tokens.up,
med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`,
diff --git a/docs/plans/status-page-themes.md b/docs/plans/status-page-themes.md
deleted file mode 100644
index eb460766e..000000000
--- a/docs/plans/status-page-themes.md
+++ /dev/null
@@ -1,304 +0,0 @@
-# Plan: Selectable public status page themes
-
-## Goal
-Let each status page owner pick from four visual themes for their public status page (`/status/public/:url`). The choice is saved on the `StatusPage` document and rendered without any change to the data flow, routes, or component tree. Also add a light/dark toggle to the public page (not currently possible).
-
-Themes:
-- `refined` (default, closest to today)
-- `modern`
-- `bold`
-- `editorial`
-
-Mockups to match: `/Users/gorkemcetin/checkmate-web/public/status-designs/01-refined.html` through `04-editorial.html`.
-
-## Non-goals
-- No changes to monitor scheduling, repositories, queue, or status computation
-- **No functionality added and no functionality removed.** Every theme renders exactly the same data and the same interactions as the current public status page — company name, logo, overall status banner with all 7 status variants (up / degraded / down / breached / maintenance / paused / initializing), monitor list with name/type/URL/status badge, heatmap/histogram chart toggle, and hardware monitor gauges (CPU/memory/disk/temperature). All existing config toggles (`showCharts`, `showUptimePercentage`, `showInfrastructure`, `isPublished`, `customCSS`, `logo`, `timezone`, `color`) continue to work identically under every theme. This is a presentation-layer refresh — nothing more, nothing less
-- No per-theme feature parity drift — if a feature isn't in the real product today, it stays out of all four themes. If it's in the product today, it must work under all four themes
-- No custom theme editor — fixed set of four
-- No repurposing of the existing unused `color` field on `StatusPage`; it stays as-is
-- `customCSS` is out of scope here; it already exists and is applied after theme tokens
-
-## Principles
-- **Tokens, not forks.** All four themes are the same component tree rendered with different CSS custom properties plus a small per-theme structural override map. No duplicate `MonitorsList`, `StatusBar`, etc.
-- **Defaults stay invisible.** Existing status pages render `refined`, which is visually equivalent to today. No migration required.
-- **Presentation layer only.** Backend change is two fields + one validator update.
-- **Owner's choice wins on the public page.** If the admin forces `themeMode="light"`, visitor preference and visitor OS setting are ignored for that page.
-- **Visitors do not change theme mode.** When `themeMode="auto"`, the visitor's OS preference (`prefers-color-scheme`) decides light vs dark — and that's final. No in-page toggle is exposed to end-visitors. The admin-forced modes (`light`, `dark`) still lock the page to that mode.
-
----
-
-## Part 1 — Backend (PR 1)
-
-### 1.1 Model
-**File:** `server/src/db/models/StatusPage.ts`
-
-Add two fields:
-
-```ts
-theme: {
- type: String,
- enum: ["refined", "modern", "bold", "editorial"],
- default: "refined",
-},
-themeMode: {
- type: String,
- enum: ["auto", "light", "dark"],
- default: "auto",
-},
-```
-
-No migration needed. Mongoose applies defaults on read for missing fields on existing documents.
-
-### 1.2 Zod validator
-**File:** `server/src/validation/statusPageValidation.ts`
-
-Extend `createStatusPageBodyValidation` (same schema reused for update):
-
-```ts
-theme: z.enum(["refined", "modern", "bold", "editorial"]).optional(),
-themeMode: z.enum(["auto", "light", "dark"]).optional(),
-```
-
-Optional so older admin clients keep working.
-
-### 1.3 Controller
-**File:** `server/src/controllers/statusPageController.ts`
-
-- `createStatusPage` / `updateStatusPage` — already spread the validated body; confirm in review no explicit allow-list strips the new fields.
-- `getStatusPageByUrl` — returns the whole document; confirm `theme` and `themeMode` appear in the JSON response (Mongoose defaults will populate them for legacy docs).
-
-### 1.4 OpenAPI / Swagger
-**File:** `server/openapi.json`
-
-Add `theme` and `themeMode` to the StatusPage schema definition and to the create/update request bodies. Include enum values and defaults.
-
-### 1.5 Feature flag (rollback safety)
-**File:** `server/src/config/` (or wherever env flags live)
-
-Add `STATUS_PAGE_THEMES_ENABLED` env var, default `true`. If the existing codebase already follows a convention (e.g. `FEATURE_*` prefix), rename to match during review — grep `server/src/config` before merging PR 1. When `false`, the controller strips `theme`/`themeMode` from writes and always responds with `theme: "refined"`, `themeMode: "auto"` on reads. Lets us disable the feature server-side without a rollback if something breaks in prod.
-
-### 1.6 Backend tests
-**File:** `server/tests/statusPage.test.ts` (create if missing — check existing patterns)
-
-Specific cases:
-- POST create with no `theme` → response and DB both have `theme: "refined"`, `themeMode: "auto"`
-- POST create with `theme: "modern"`, `themeMode: "dark"` → persists
-- PATCH update changes `theme` and `themeMode` → persists
-- POST with `theme: "invalid"` → 400
-- POST with `themeMode: "invalid"` → 400
-- GET by URL on a legacy document (no `theme` field written) → response has defaults populated
-- With `STATUS_PAGE_THEMES_ENABLED=false`, create with `theme: "modern"` → stored value is `"refined"` (or field not stored) and response reflects refined
-
----
-
-## Part 2 — Frontend (PR 2)
-
-### 2.1 Client type
-**File:** wherever `StatusPage` is typed on the client (likely `client/src/Utils/...` or inline in `Pages/StatusPage/Create/index.tsx`)
-
-Add:
-
-```ts
-theme?: "refined" | "modern" | "bold" | "editorial";
-themeMode?: "auto" | "light" | "dark";
-```
-
-Both optional for backward compat with API responses from older servers during rollout.
-
-### 2.2 Theme tokens module
-**New file:** `client/src/Pages/StatusPage/Status/themes/tokens.ts`
-
-```ts
-export type StatusPageTheme = "refined" | "modern" | "bold" | "editorial";
-export type StatusPageThemeMode = "auto" | "light" | "dark";
-
-export interface StatusPageThemeTokens {
- bg: string; surface: string; border: string;
- text: string; textMuted: string;
- up: string; upSoft: string;
- degraded: string; degradedSoft: string;
- down: string; downSoft: string;
- warn: string; warnSoft: string;
- radius: string;
- fontFamily?: string;
- headingFontFamily?: string;
- headingWeight?: number;
- headingLetterSpacing?: string;
- chartBarRadius?: string;
- // per-theme structural flags
- pulseStatusDot?: boolean;
- staggeredCardFadeIn?: boolean;
- conicLogo?: boolean;
- heroSize?: "default" | "large";
- cardStyle?: "card" | "hairline"; // hairline = editorial
-}
-
-export const themeTokens: Record<
- StatusPageTheme,
- { light: StatusPageThemeTokens; dark: StatusPageThemeTokens }
->;
-```
-
-**Source of truth:** port exact CSS variable values from each mockup's `:root` and `[data-theme="dark"]` blocks. These are the same HTML files already at `/Users/gorkemcetin/checkmate-web/public/status-designs/` so the values are pinned.
-
-### 2.3 Theme provider wrapper
-**New file:** `client/src/Pages/StatusPage/Status/themes/StatusPageThemeProvider.tsx`
-
-Responsibilities:
-- Read `theme` + `themeMode` from props (passed by the parent `Status` page from the fetched status page document)
-- Resolve the active mode with this precedence: admin-forced mode (`themeMode !== "auto"`) > `prefers-color-scheme` > light. Visitors do not have a per-page override.
-- Flatten the token object to inline CSS variables (`--sp-bg`, `--sp-up`, etc.) on a wrapper ``
-- Expose a React context with `{ theme, mode, setMode, tokens }` so components can read structural flags without prop drilling
-
-### 2.4 Theme override stylesheet
-**New file:** `client/src/Pages/StatusPage/Status/themes/theme-overrides.css`
-
-Scoped stylesheet, selectors all prefixed with `[data-status-theme="..."]`. Covers the three structural differences that aren't expressible as token swaps:
-
-- `[data-status-theme="editorial"]` — serif heading font (**Georgia**, system font, no webfont to keep bundle lean), monitor cards become hairline-divided rows (no card borders, no shadow, bottom border only), flat badges, no rounded corners on charts
-- `[data-status-theme="bold"]` — larger hero typography, conic-gradient logo treatment **only when no custom logo is uploaded**. Fallback visual: a conic-gradient disc containing the first letter of the status page's `companyName` as a monogram (matches the "A" shown in the mockup). When a custom logo is uploaded, render it plainly and skip the conic flair
-- `[data-status-theme="modern"]` — pulse animation on the status dot, staggered fade-in on monitor cards
-
-All the simple color/radius/spacing differences stay in tokens, not here.
-
-### 2.5 Wire up the public page
-**Files to touch (styling only, no structural changes):**
-- `client/src/Pages/StatusPage/Status/index.tsx` — wrap `StatusPageView` in `StatusPageThemeProvider`, pass `theme`/`themeMode` from the fetched document
-- `client/src/Pages/StatusPage/Status/Components/StatusBar.tsx`
-- `client/src/Pages/StatusPage/Status/Components/MonitorsList.tsx`
-- `client/src/Pages/StatusPage/Status/Components/MonitorHeader.tsx` (confirm path in review — Explore said "if exists")
-- `client/src/Pages/StatusPage/Status/Components/MonitorContent.tsx`
-- `client/src/Pages/StatusPage/Status/Components/InfrastructureMetrics.tsx`
-- `client/src/Pages/StatusPage/Status/Components/HeaderStatusPageControls.tsx`
-
-Rule: within this subtree, read status-page-specific colors from `var(--sp-up)`, `var(--sp-surface)`, etc. instead of `theme.palette.*`. MUI theme is still used for spacing, breakpoints, and typography defaults.
-
-### 2.6 Chart components — explicit decision
-**Files:** `client/src/Components/common/charts/HistogramResponseTime.tsx`, `HeatmapResponseTime.tsx`
-
-These are shared with non-status-page screens. Two options:
-1. Leave them reading MUI theme (simplest). Accept that on `bold` dark theme the chart bars may look slightly off-brand versus the mockups.
-2. Accept an optional `paletteOverride` prop and pass `{ up, degraded, down }` from the status page theme when rendered inside `StatusPageThemeProvider`.
-
-**Decision:** go with (2). It's a few lines of prop plumbing and keeps the mockups looking right. The components stay backwards-compatible because `paletteOverride` is optional.
-
-### 2.7 Public-page light/dark behaviour (no visitor toggle)
-The public page does **not** expose a visitor-facing mode toggle. Per the "Visitors do not change theme mode" principle:
-- `themeMode="auto"` → page reads `prefers-color-scheme` at load and **live-updates** via a `matchMedia` listener if the visitor's OS flips mode mid-session
-- `themeMode="light"` or `"dark"` → page is locked to that mode regardless of visitor setting
-
-The admin dashboard keeps its global light/dark toggle (unchanged). No new `ThemeModeToggle` component is built; no localStorage key is added.
-
-### 2.8 Admin selector with previews
-**File:** `client/src/Pages/StatusPage/Create/Components/HeaderConfigStatusControls.tsx` (confirm in review — may be elsewhere in the `Create/` tree)
-
-- **Access control:** the picker is shown to users who can already edit the status page (admins and team admins — gate on the existing permission check, don't add a new one). Anyone who can save other status page settings can pick a theme.
-- Card-style radio group, 2×2 grid on desktop, 1-column on mobile
-- Each option card shows:
- - Theme name (translated)
- - 1-line description
- - A **side-by-side light + dark preview** so admins see both modes at a glance (two SVGs per theme rendered next to each other within the card, labelled "Light" / "Dark")
- - On hover, the card enlarges the currently-hovered preview (light or dark depending on cursor position half) — required in PR 2
- - Radio indicator
-- Second form control below: `themeMode` dropdown with three options (auto / light / dark), translated
-- Hook into the existing form state, write `theme` + `themeMode` to the create/update payload
-
-### 2.9 Preview thumbnails
-**Assets:** `client/src/assets/status-themes/{refined,modern,bold,editorial}-{light,dark}.svg` (8 files total)
-
-- Hand-authored SVG, ~260×160 each (sized to sit side-by-side inside an admin card), illustrating the key visual traits of its theme (a simulated status banner + one monitor row with a tiny heatmap)
-- Each ≤4KB, no embedded fonts
-- Both variants per theme are always rendered in the picker card (not conditionally by admin UI mode) so admins compare light and dark without changing their UI preference
-- Committed directly — no bundled screenshots, no external fetches
-
-### 2.10 i18n
-**File:** `client/src/locales/en/translation.json` — other locales follow existing contribution flow
-
-Keys to add:
-- `statusPage.settings.theme.label`
-- `statusPage.settings.theme.description`
-- `statusPage.settings.theme.options.refined.name` / `.description`
-- `statusPage.settings.theme.options.modern.name` / `.description`
-- `statusPage.settings.theme.options.bold.name` / `.description`
-- `statusPage.settings.theme.options.editorial.name` / `.description`
-- `statusPage.settings.themeMode.label`
-- `statusPage.settings.themeMode.description`
-- `statusPage.settings.themeMode.auto` / `.light` / `.dark`
-- `statusPage.settings.theme.previewLightLabel`
-- `statusPage.settings.theme.previewDarkLabel`
-
-### 2.11 Frontend tests
-**Component tests (existing test setup):**
-- Admin form: selecting a theme card updates form state and survives submit
-- Admin form: `themeMode` dropdown changes payload
-- `StatusPageThemeProvider`: `auto` mode resolves to `prefers-color-scheme` when no localStorage; localStorage overrides when set; admin-forced mode wins over both
-- Mode resolution: `themeMode="auto"` picks up `prefers-color-scheme`; `themeMode="light"` and `"dark"` lock the mode regardless of OS setting
-- Admin picker shows both light + dark preview SVGs for every theme, regardless of admin UI mode
-- Jest/Vitest snapshot tests (plain, no visual-regression tool): `Status/index.tsx` rendered under each of `refined | modern | bold | editorial` × `light | dark`. 8 snapshots via a single parametrised test.
-
-### 2.12 Accessibility checks
-Run axe (or the existing a11y test harness) against each theme × mode combination before merging. Required passes:
-- Body text ≥ 4.5:1 contrast
-- Badges, UI component borders, and chart cells ≥ 3:1 contrast
-- Editorial in particular: verify the hairline divider between monitor rows clears 3:1 (beige `#e9e4d8` on `#fbfaf7` is ~1.3:1, which fails — darken to ~`#d4cdb8` or heavier)
-
-Adjust tokens if any combination fails. Don't ship failing themes.
-
----
-
-## Rollout
-
-- **PR 1 (backend)** — model + validator + controller confirmation + OpenAPI + feature flag + tests. No visible change. Shippable independently; older clients keep working; theme is always `refined` in responses because the client ignores the field until PR 2 lands.
-- **PR 2 (frontend)** — theme tokens + provider + wire-up + chart palette override + admin picker with previews + public toggle + i18n + tests + axe pass.
-
-Two PRs, small-enough individually to review carefully.
-
----
-
-## Risks and mitigations
-
-| Risk | Mitigation |
-| --- | --- |
-| Four themes = 4× visual-regression surface going forward | Parametrised snapshot tests in PR 2 cover all 4 themes × both modes × key status states. Regressions caught at PR time. |
-| Editorial hairline dividers fail WCAG 3:1 for UI borders | Axe pass in 2.12; darken divider token until it clears. |
-| Older clients posting create/update without `theme` break | Validator makes fields optional; model default handles it. Covered by backend test 1.6. |
-| Per-page localStorage theme mode leaks across status pages | Storage key namespaced by `statusPage.url`. Covered in 2.3 and 2.11. |
-| `customCSS` collides with theme tokens | Themes set CSS variables on a wrapper; `customCSS` is injected inline after and can override any `--sp-*` variable. No code change. Document in settings help text. |
-| Shared chart components look off-brand on bold/modern dark | Decided in 2.6: pass an optional `paletteOverride` prop from the status page subtree. |
-| Rollback needed if feature misbehaves in prod | Feature flag `STATUS_PAGE_THEMES_ENABLED` in 1.5 disables writes and forces `refined` on reads. |
-| Admin picker previews go stale as themes drift | SVGs live next to token file; note in `tokens.ts` header comment: "If you change tokens here, update the preview SVGs." A future improvement is to render previews from tokens dynamically; out of scope. |
-| Editorial structural override CSS leaks outside status page | All selectors prefixed with `[data-status-theme="..."]`. The wrapper div is scoped to `Status/index.tsx`, so the attribute only exists inside that subtree. |
-
----
-
-## Out-of-scope parking lot
-- Custom theme editor with user-chosen colors
-- Per-monitor theme overrides
-- Status-page-level favicon + meta theme-color sync
-- Live preview pane in admin while editing
-- Dynamic preview rendering from tokens (replaces hand-authored SVGs)
-
----
-
-## Verification checklist before merging each PR
-
-### PR 1 (backend)
-- [ ] `theme` + `themeMode` fields added with correct defaults
-- [ ] Zod validator accepts valid enums, rejects invalid values
-- [ ] Legacy docs (no theme field) return `"refined"`/`"auto"` on GET
-- [ ] OpenAPI spec updated and regenerated
-- [ ] Feature flag present and tested in both states
-- [ ] All tests enumerated in 1.6 pass
-
-### PR 2 (frontend)
-- [ ] **Functional parity audit:** walk the pre-change public status page and the post-change page under each of the four themes. Confirm every visible data point, every config toggle, and every interaction (chart type switch, heatmap tooltip, hardware gauge rendering, status variant coloring, logo display, URL display, `customCSS` injection) behaves identically. No feature missing from any theme. No new feature present in any theme.
-- [ ] All four mockups render faithfully at `/status-designs/0N-*.html` parity
-- [ ] Default case (no theme set) renders indistinguishably from pre-change public page
-- [ ] No MUI theme mutations leak outside `Pages/StatusPage/Status/`
-- [ ] Admin picker previews render in both light and dark admin UI modes
-- [ ] Public page resolves mode correctly: `auto` → OS preference; `light`/`dark` → locked; no visitor-facing toggle exists
-- [ ] Bold theme's conic-logo treatment applies only when no custom logo is uploaded; custom logos render plainly
-- [ ] Axe passes AA on all 4 × 2 theme/mode combinations, including for chart cells
-- [ ] i18n keys added to `en` and loaded without warnings
-- [ ] Snapshot tests in place for all theme × mode combinations