feat(status-page): four selectable themes for the public status page

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.
This commit is contained in:
gorkem-bwl committed 2026-04-23 22:51:40 -04:00
1 parent ad964446de
commit 944ee74a1d
28 files changed
+3967 -114

No files matched your search

+3 -1
View File
@@ -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
+6
View File
@@ -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),
};
@@ -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 (
<Stack spacing={muiTheme.spacing(6)}>
<RadioGroup
value={theme}
onChange={(_, value) => onThemeChange(value as StatusPageTheme)}
>
<Stack spacing={muiTheme.spacing(4)}>
{STATUS_PAGE_THEMES.map((option) => (
<RadioWithDescription
key={option}
value={option}
label={t(`pages.statusPages.form.theme.options.${option}.name`)}
description={t(
`pages.statusPages.form.theme.options.${option}.description`
)}
/>
))}
</Stack>
</RadioGroup>
<Select
fieldLabel={t("pages.statusPages.form.themeMode.label")}
value={themeMode}
onChange={(e) => onThemeModeChange(e.target.value as StatusPageThemeMode)}
>
{STATUS_PAGE_THEME_MODES.map((mode) => (
<MenuItem
key={mode}
value={mode}
>
{t(`pages.statusPages.form.themeMode.${mode}`)}
</MenuItem>
))}
</Select>
</Stack>
);
};
@@ -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 = () => {
</Stack>
}
/>
<ConfigBox
title={t("pages.statusPages.form.theme.title")}
subtitle={t("pages.statusPages.form.theme.description")}
rightContent={
<Controller
name="theme"
control={control}
render={({ field: themeField }) => (
<Controller
name="themeMode"
control={control}
render={({ field: modeField }) => (
<ThemePicker
theme={themeField.value ?? "refined"}
themeMode={modeField.value ?? "auto"}
onThemeChange={themeField.onChange}
onThemeModeChange={modeField.onChange}
/>
)}
/>
)}
/>
}
/>
<ConfigBox
title={t("pages.statusPages.form.features.title")}
subtitle={t("pages.statusPages.form.features.description")}
@@ -37,6 +37,7 @@ export const HeaderStatusPageControls = ({
>
<Typography
variant="h1"
className="sp-heading"
overflow="hidden"
textOverflow="ellipsis"
sx={{
@@ -63,12 +63,14 @@ const MonitorHeader = ({
>
<Typography
variant="h6"
className="sp-heading"
sx={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{monitor.name}
</Typography>
<Typography
variant="caption"
className="sp-badge"
sx={getMonitorBadgeStyles(monitor.type ?? "", theme)}
>
{getMonitorTypeLabel(monitor.type, t)}
@@ -153,6 +155,7 @@ export const MonitorsList = ({ statusPage, monitors }: MonitorsListProps) => {
{monitors.map((monitor) => (
<BaseBox
key={monitor.id}
className="sp-card"
padding={theme.spacing(LAYOUT.MD)}
>
<MonitorHeader
@@ -12,26 +12,23 @@ import Typography from "@mui/material/Typography";
import { useTranslation } from "react-i18next";
import { useTheme } from "@mui/material";
import type { Theme } from "@mui/material";
import type { Monitor, MonitorStatus } from "@/Types/Monitor";
type SemanticTone = "up" | "down" | "warn" | "info";
interface StatusDisplay {
icon: JSX.Element;
msg?: string;
color?: string;
tone: SemanticTone;
}
const getMonitorStatus = (monitors: Monitor[], theme: Theme, t: Function) => {
const monitorsStatus: StatusDisplay = {
icon: <AlertTriangle size={24} />,
};
// 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 = <CircleX size={24} />;
return monitorsStatus;
return {
msg: t("pages.statusPages.statusBar.noMonitors"),
tone: "warn",
icon: <CircleX size={24} />,
};
}
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 = <CircleCheck size={24} />;
} else if (allOf("breached")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allBreached");
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <ShieldAlert size={24} />;
} else if (allOf("maintenance")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allMaintenance");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <Wrench size={24} />;
} else if (allOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allDown");
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <CircleX size={24} />;
} else if (allOf("paused")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allPaused");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <PauseCircle size={24} />;
} else if (allOf("initializing")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.allInitializing");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <Loader size={24} />;
// 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 = <ShieldAlert size={24} />;
} else if (someOf("breached")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.breached");
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <ShieldAlert size={24} />;
// Maintenance combinations
} else if (someOf("maintenance") && someOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.maintenanceAndDown");
monitorsStatus.color = theme.palette.error.main;
monitorsStatus.icon = <Wrench size={24} />;
} else if (someOf("maintenance") && noneOf("down")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.maintenance");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <Wrench size={24} />;
// 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 = <AlertTriangle size={24} />;
// Some Paused
} else if (someOf("paused")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.partiallyPaused");
monitorsStatus.color = theme.palette.warning.main;
monitorsStatus.icon = <PauseCircle size={24} />;
// Initializing
} else if (someOf("initializing")) {
monitorsStatus.msg = t("pages.statusPages.statusBar.initializing");
monitorsStatus.color = theme.palette.info.main;
monitorsStatus.icon = <Loader size={24} />;
} else {
monitorsStatus.msg = t("pages.statusPages.statusBar.unknown");
monitorsStatus.color = theme.palette.warning.main;
return {
msg: t("pages.statusPages.statusBar.allUp"),
tone: "up",
icon: <CircleCheck size={24} />,
};
}
if (allOf("breached")) {
return {
msg: t("pages.statusPages.statusBar.allBreached"),
tone: "down",
icon: <ShieldAlert size={24} />,
};
}
if (allOf("maintenance")) {
return {
msg: t("pages.statusPages.statusBar.allMaintenance"),
tone: "warn",
icon: <Wrench size={24} />,
};
}
if (allOf("down")) {
return {
msg: t("pages.statusPages.statusBar.allDown"),
tone: "down",
icon: <CircleX size={24} />,
};
}
if (allOf("paused")) {
return {
msg: t("pages.statusPages.statusBar.allPaused"),
tone: "warn",
icon: <PauseCircle size={24} />,
};
}
if (allOf("initializing")) {
return {
msg: t("pages.statusPages.statusBar.allInitializing"),
tone: "warn",
icon: <Loader size={24} />,
};
}
return monitorsStatus;
// Breached takes highest priority in mixed states
if (someOf("breached") && someOf("down")) {
return {
msg: t("pages.statusPages.statusBar.breachedAndDown"),
tone: "down",
icon: <ShieldAlert size={24} />,
};
}
if (someOf("breached")) {
return {
msg: t("pages.statusPages.statusBar.breached"),
tone: "down",
icon: <ShieldAlert size={24} />,
};
}
if (someOf("maintenance") && someOf("down")) {
return {
msg: t("pages.statusPages.statusBar.maintenanceAndDown"),
tone: "down",
icon: <Wrench size={24} />,
};
}
if (someOf("maintenance") && noneOf("down")) {
return {
msg: t("pages.statusPages.statusBar.maintenance"),
tone: "warn",
icon: <Wrench size={24} />,
};
}
if (someOf("down")) {
return {
msg: t("pages.statusPages.statusBar.degraded"),
tone: "warn",
icon: <AlertTriangle size={24} />,
};
}
if (someOf("paused")) {
return {
msg: t("pages.statusPages.statusBar.partiallyPaused"),
tone: "warn",
icon: <PauseCircle size={24} />,
};
}
if (someOf("initializing")) {
return {
msg: t("pages.statusPages.statusBar.initializing"),
tone: "info",
icon: <Loader size={24} />,
};
}
return {
msg: t("pages.statusPages.statusBar.unknown"),
tone: "warn",
icon: <AlertTriangle size={24} />,
};
};
const toneToCssVar: Record<SemanticTone, string> = {
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 (
<Stack
@@ -128,11 +162,12 @@ export const StatusBar = ({ monitors }: StatusBarProps) => {
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}
<Typography>{monitorsStatus.msg}</Typography>
<Typography className="sp-hero-title">{monitorsStatus.msg}</Typography>
</Stack>
);
};
+107 -32
View File
@@ -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<StatusPageTheme, React.ComponentType<ThemedRendererProps>>
> = {
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 (
<BasePage
loading={isLoading}
error={error}
sx={sx}
breadcrumbOverride={isPublic ? [] : undefined}
>
<HeaderStatusPageControls
isAdmin={isAdmin}
statusPage={statusPage}
isPublic={isPublic}
/>
{logoSrc && (
<Box
component="img"
src={logoSrc}
alignSelf={"flex-start"}
alt={statusPage.companyName}
sx={{
maxHeight: 120,
maxWidth: "100%",
objectFit: "contain",
mb: 2,
}}
const ThemedRenderer = statusPage.theme
? THEMED_RENDERERS[statusPage.theme]
: undefined;
const themedRenderer = ThemedRenderer ? (
<ThemedRenderer
statusPage={statusPage}
monitors={monitors}
/>
) : null;
if (themedRenderer) {
// Public route: render directly on the viewport, themed background covers everything.
if (isPublic) {
return (
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
paintBody
>
{themedRenderer}
</StatusPageThemeProvider>
);
}
// Admin preview: wrap in a mock browser frame inside the admin shell.
const publicUrl = `${window.location.origin}/status/public/${statusPage.url}`;
return (
<BasePage
loading={isLoading}
error={error}
breadcrumbOverride={undefined}
sx={{ flex: 1, minHeight: 0 }}
>
<HeaderStatusPageControls
isAdmin={isAdmin}
statusPage={statusPage}
isPublic={false}
/>
)}
<StatusBar monitors={monitors} />
<MonitorsList
statusPage={statusPage}
monitors={monitors}
/>
</BasePage>
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
transparent
>
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
</StatusPageThemeProvider>
</BasePage>
);
}
return (
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
>
<BasePage
loading={isLoading}
error={error}
sx={sx}
breadcrumbOverride={isPublic ? [] : undefined}
>
<HeaderStatusPageControls
isAdmin={isAdmin}
statusPage={statusPage}
isPublic={isPublic}
/>
{logoSrc && (
<Box
component="img"
src={logoSrc}
alignSelf={"flex-start"}
alt={statusPage.companyName}
sx={{
maxHeight: 120,
maxWidth: "100%",
objectFit: "contain",
mb: 2,
}}
/>
)}
<StatusBar monitors={monitors} />
<MonitorsList
statusPage={statusPage}
monitors={monitors}
/>
</BasePage>
</StatusPageThemeProvider>
);
};
@@ -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 (
<Box
sx={{
width: "100%",
minHeight: "calc(100vh - 180px)",
display: "flex",
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 + ")",
}}
>
<Box
sx={{
display: "flex",
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 + ")",
}}
>
<Box sx={{ display: "flex", gap: 0.75 }}>
<Box
sx={{ width: 12, height: 12, borderRadius: "50%", background: "#ff5f57" }}
/>
<Box
sx={{ width: 12, height: 12, borderRadius: "50%", background: "#febc2e" }}
/>
<Box
sx={{ width: 12, height: 12, borderRadius: "50%", background: "#28c840" }}
/>
</Box>
<Box
sx={{
flex: 1,
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 + ")",
fontFamily: "ui-monospace, Menlo, monospace",
fontSize: 12,
color: "var(--sp-text-muted, " + theme.palette.text.secondary + ")",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{url}
</Box>
</Box>
<Box sx={{ flex: 1, minHeight: 0 }}>{children}</Box>
</Box>
);
};
@@ -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<StatusPageThemeContextValue | null>(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<ResolvedMode>(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 (
<StatusPageThemeContext.Provider value={value}>
<div
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",
}
}
>
{children}
</div>
</StatusPageThemeContext.Provider>
);
};
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;
};
@@ -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 (
<Box className={`${PREFIX}-page`}>
<Box
component="header"
className={`${PREFIX}-top`}
>
<Box className={`${PREFIX}-brand`}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
className={`${PREFIX}-logo-img`}
/>
) : (
<Box className={`${PREFIX}-logo-conic`}>
{monoFirstChar(statusPage.companyName)}
</Box>
)}
<span className={`${PREFIX}-company`}>{statusPage.companyName}</span>
</Box>
</Box>
<Box className={`${PREFIX}-hero ${PREFIX}-tone-${overall.tone}`}>
<h1 className={`${PREFIX}-hero-title`}>
<span className={`${PREFIX}-hero-check`}>{overall.icon}</span>
{overall.message}
</h1>
<p className={`${PREFIX}-hero-sub`}>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</p>
</Box>
{statusPage.showCharts && (
<Box className={`${PREFIX}-chart-switch-wrap`}>
<Box
className={`${PREFIX}-chart-switch`}
role="radiogroup"
>
<button
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
className={chartMode === "heatmap" ? `${PREFIX}-active` : ""}
onClick={() => setChartMode("heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</button>
<button
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
className={chartMode === "histogram" ? `${PREFIX}-active` : ""}
onClick={() => setChartMode("histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</button>
</Box>
</Box>
)}
<Stack
component="ul"
className={`${PREFIX}-monitor-list`}
>
{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 (
<Box
component="li"
key={monitor.id}
className={`${PREFIX}-card ${PREFIX}-card-${monitor.status}`}
>
<Box className={`${PREFIX}-card-row`}>
<Box className={`${PREFIX}-card-left`}>
<Box className={`${PREFIX}-monitor-name`}>{monitor.name}</Box>
<Box className={`${PREFIX}-monitor-meta`}>
<span
className={`${PREFIX}-pill ${PREFIX}-pill-${isHardware ? "hardware" : "default"}`}
>
{getMonitorTypeLabel(monitor.type, t)}
</span>
{monitor.url && (
<span
className={`${PREFIX}-monitor-url`}
title={monitor.url}
>
{monitor.url}
</span>
)}
</Box>
</Box>
<span className={`${PREFIX}-badge ${PREFIX}-badge-${badgeTone}`}>
{t(statusBadgeKey[monitor.status])}
</span>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
classPrefix={PREFIX}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
classPrefix={PREFIX}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
classPrefix={PREFIX}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
className={`${PREFIX}-footer`}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -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;
}
@@ -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 (
<Box className={`${PREFIX}-page`}>
<Box
component="header"
className={`${PREFIX}-top`}
>
<Box className={`${PREFIX}-brand-wrap`}>
{logoSrc && (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
className={`${PREFIX}-logo-img`}
/>
)}
<h2 className={`${PREFIX}-brand-eyebrow`}>{statusPage.companyName}</h2>
<h1 className={`${PREFIX}-brand-title`}>
{t("pages.statusPages.editorial.reportTitle")}
</h1>
</Box>
</Box>
<p className={`${PREFIX}-status-line ${PREFIX}-tone-${overall.tone}`}>
<span className={`${PREFIX}-status-dot`} />
{overall.message}
</p>
<p className={`${PREFIX}-dateline`}>
{todayLabel} ·{" "}
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</p>
{statusPage.showCharts && (
<Box className={`${PREFIX}-chart-switch-wrap`}>
<Box
className={`${PREFIX}-chart-switch`}
role="radiogroup"
>
<button
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
className={chartMode === "heatmap" ? `${PREFIX}-active` : ""}
onClick={() => setChartMode("heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</button>
<button
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
className={chartMode === "histogram" ? `${PREFIX}-active` : ""}
onClick={() => setChartMode("histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</button>
</Box>
</Box>
)}
<Stack
component="ul"
className={`${PREFIX}-monitor-list`}
>
{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 (
<Box
component="li"
key={monitor.id}
className={`${PREFIX}-card ${PREFIX}-card-${monitor.status}`}
>
<Box className={`${PREFIX}-card-row`}>
<Box className={`${PREFIX}-card-left`}>
<Box className={`${PREFIX}-monitor-name`}>{monitor.name}</Box>
<Box className={`${PREFIX}-monitor-meta`}>
<span
className={`${PREFIX}-pill ${PREFIX}-pill-${isHardware ? "hardware" : "default"}`}
>
{getMonitorTypeLabel(monitor.type, t)}
</span>
{monitor.url && (
<span
className={`${PREFIX}-monitor-url`}
title={monitor.url}
>
{monitor.url}
</span>
)}
</Box>
</Box>
<span className={`${PREFIX}-badge ${PREFIX}-badge-${badgeTone}`}>
{t(statusBadgeKey[monitor.status])}
</span>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
classPrefix={PREFIX}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
classPrefix={PREFIX}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
classPrefix={PREFIX}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
className={`${PREFIX}-footer`}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -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;
}
@@ -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 (
<Box className={`${PREFIX}-page`}>
<Box
component="header"
className={`${PREFIX}-top`}
>
<Box className={`${PREFIX}-brand`}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
className={`${PREFIX}-logo-img`}
/>
) : (
<Box className={`${PREFIX}-logo-grad`}>
{monoFirstChar(statusPage.companyName)}
</Box>
)}
<span className={`${PREFIX}-company`}>{statusPage.companyName}</span>
</Box>
</Box>
<Box className={`${PREFIX}-hero ${PREFIX}-tone-${overall.tone}`}>
<Box className={`${PREFIX}-pulse`} />
<Box className={`${PREFIX}-status-copy`}>
<h1 className={`${PREFIX}-hero-title`}>{overall.message}</h1>
<p className={`${PREFIX}-hero-sub`}>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</p>
</Box>
<Box className={`${PREFIX}-hero-icon`}>{overall.icon}</Box>
</Box>
{statusPage.showCharts && (
<Box className={`${PREFIX}-chart-switch-wrap`}>
<Box
className={`${PREFIX}-chart-switch`}
role="radiogroup"
>
<button
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
className={chartMode === "heatmap" ? `${PREFIX}-active` : ""}
onClick={() => setChartMode("heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</button>
<button
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
className={chartMode === "histogram" ? `${PREFIX}-active` : ""}
onClick={() => setChartMode("histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</button>
</Box>
</Box>
)}
<Stack
component="ul"
className={`${PREFIX}-monitor-list`}
>
{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 (
<Box
component="li"
key={monitor.id}
className={`${PREFIX}-card ${PREFIX}-card-${monitor.status}`}
>
<Box className={`${PREFIX}-card-row`}>
<Box className={`${PREFIX}-card-left`}>
<Box className={`${PREFIX}-monitor-name`}>{monitor.name}</Box>
<Box className={`${PREFIX}-monitor-meta`}>
<span
className={`${PREFIX}-pill ${PREFIX}-pill-${isHardware ? "hardware" : "default"}`}
>
{getMonitorTypeLabel(monitor.type, t)}
</span>
{monitor.url && (
<span
className={`${PREFIX}-monitor-url`}
title={monitor.url}
>
{monitor.url}
</span>
)}
</Box>
</Box>
<span className={`${PREFIX}-badge ${PREFIX}-badge-${badgeTone}`}>
{t(statusBadgeKey[monitor.status])}
</span>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
classPrefix={PREFIX}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
classPrefix={PREFIX}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
classPrefix={PREFIX}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
className={`${PREFIX}-footer`}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -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;
}
}
@@ -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 (
<Box className={`${PREFIX}-page`}>
<Box
component="header"
className={`${PREFIX}-top`}
>
<Box className={`${PREFIX}-brand`}>
{logoSrc ? (
<Box
component="img"
src={logoSrc}
alt={statusPage.companyName}
className={`${PREFIX}-logo-img`}
/>
) : (
<Box className={`${PREFIX}-logo-mono`}>
{monoFirstChar(statusPage.companyName)}
</Box>
)}
<span className={`${PREFIX}-company`}>{statusPage.companyName}</span>
</Box>
</Box>
<Box className={`${PREFIX}-hero ${PREFIX}-tone-${overall.tone}`}>
<Box className={`${PREFIX}-status-dot`} />
<Box className={`${PREFIX}-status-copy`}>
<h1 className={`${PREFIX}-hero-title`}>{overall.message}</h1>
<p className={`${PREFIX}-hero-sub`}>
{t("pages.statusPages.statusBar.monitoringSummary", {
count: monitors.length,
})}
</p>
</Box>
<Box className={`${PREFIX}-hero-icon`}>{overall.icon}</Box>
</Box>
{statusPage.showCharts && (
<Box className={`${PREFIX}-chart-switch-wrap`}>
<Box
className={`${PREFIX}-chart-switch`}
role="radiogroup"
>
<button
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
className={chartMode === "heatmap" ? `${PREFIX}-active` : ""}
onClick={() => setChartMode("heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</button>
<button
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
className={chartMode === "histogram" ? `${PREFIX}-active` : ""}
onClick={() => setChartMode("histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</button>
</Box>
</Box>
)}
<Stack
component="ul"
className={`${PREFIX}-monitor-list`}
>
{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 (
<Box
component="li"
key={monitor.id}
className={`${PREFIX}-card ${PREFIX}-card-${monitor.status}`}
>
<Box className={`${PREFIX}-card-row`}>
<Box className={`${PREFIX}-card-left`}>
<Box className={`${PREFIX}-monitor-name`}>{monitor.name}</Box>
<Box className={`${PREFIX}-monitor-meta`}>
<span
className={`${PREFIX}-pill ${PREFIX}-pill-${isHardware ? "hardware" : "default"}`}
>
{getMonitorTypeLabel(monitor.type, t)}
</span>
{monitor.url && (
<span
className={`${PREFIX}-monitor-url`}
title={monitor.url}
>
{monitor.url}
</span>
)}
</Box>
</Box>
<span className={`${PREFIX}-badge ${PREFIX}-badge-${badgeTone}`}>
{t(statusBadgeKey[monitor.status])}
</span>
</Box>
{showInfra && (
<ThemedInfrastructure
monitor={monitor}
classPrefix={PREFIX}
/>
)}
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
classPrefix={PREFIX}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
classPrefix={PREFIX}
/>
))}
</Box>
);
})}
</Stack>
<Box
component="footer"
className={`${PREFIX}-footer`}
>
{t("pages.statusPages.footer.poweredBy")}{" "}
<a
href="https://checkmate.so"
target="_blank"
rel="noopener noreferrer"
>
Checkmate
</a>
</Box>
</Box>
);
};
@@ -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));
}
@@ -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 (
<Box
className={`${classPrefix}-heatmap`}
role="img"
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
>
{padded.map((check, i) => {
if (!check) {
return (
<Box
key={`empty-${i}`}
className={`${classPrefix}-heatmap-cell ${classPrefix}-empty`}
/>
);
}
const cls = classify(check);
const tooltipContent = (
<Stack gap={0.25}>
<Typography
variant="caption"
fontWeight={600}
>
{check.status
? `${check.responseTime} ms`
: t("pages.statusPages.monitorsList.chart.downTooltip")}
</Typography>
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{formatCheckTimestamp(check.createdAt as unknown as string)}
</Typography>
</Stack>
);
return (
<Tooltip
key={check.id ?? i}
title={tooltipContent}
arrow
placement="top"
>
<Box
className={`${classPrefix}-heatmap-cell ${classPrefix}-${cls}`}
aria-label={`${check.responseTime} ms, ${check.status ? "up" : "down"}`}
/>
</Tooltip>
);
})}
</Box>
);
};
@@ -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 (
<Stack gap={statsGap}>
<Box className={`${classPrefix}-histogram`}>
{padded.map((check, i) => {
if (!check) {
return (
<Box
key={`empty-${i}`}
className={`${classPrefix}-bar ${classPrefix}-empty`}
style={{ height: `${MIN_HEIGHT_PCT}%` }}
/>
);
}
const height = Math.max(
MIN_HEIGHT_PCT,
Math.round((check.responseTime / max) * 100)
);
const tooltipContent = (
<Stack gap={0.25}>
<Typography
variant="caption"
fontWeight={600}
>
{check.status
? `${check.responseTime} ms`
: t("pages.statusPages.monitorsList.chart.downTooltip")}
</Typography>
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{formatCheckTimestamp(check.createdAt as unknown as string)}
</Typography>
</Stack>
);
return (
<Tooltip
key={check.id ?? i}
title={tooltipContent}
arrow
placement="top"
>
<Box
className={`${classPrefix}-bar ${classPrefix}-${tone(check)}`}
style={{ height: `${height}%` }}
aria-label={`${check.responseTime} ms`}
/>
</Tooltip>
);
})}
</Box>
<Stack
direction="row"
justifyContent="space-between"
className={`${classPrefix}-chart-stats`}
>
<span>{t("pages.statusPages.monitorsList.chart.avg", { value: avg })}</span>
<span>{t("pages.statusPages.monitorsList.chart.max", { value: peak })}</span>
</Stack>
</Stack>
);
};
@@ -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 (
<Box className={`${classPrefix}-infra-empty`}>
{t("pages.statusPages.monitorsList.noData")}
</Box>
);
}
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 (
<Box className={`${classPrefix}-infra-empty`}>
{t("pages.statusPages.monitorsList.noData")}
</Box>
);
}
return (
<Box className={`${classPrefix}-infra`}>
{gauges.map((g) => {
const heatClass =
g.value > 85 ? `${classPrefix}-hot` : g.value > 70 ? `${classPrefix}-warm` : "";
return (
<Box
key={g.key}
className={`${classPrefix}-gauge`}
>
<Box className={`${classPrefix}-gauge-label`}>{g.label}</Box>
<Box className={`${classPrefix}-gauge-value`}>{g.value.toFixed(0)}%</Box>
<Box className={`${classPrefix}-gauge-bar`}>
<Box
className={`${classPrefix}-gauge-fill ${heatClass}`}
style={{ width: `${Math.min(100, g.value)}%` }}
/>
</Box>
<Box className={`${classPrefix}-gauge-sub`}>{g.sub}</Box>
</Box>
);
})}
</Box>
);
};
@@ -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<Monitor, "status">;
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: <CircleX size={size} />,
};
}
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: <CircleCheck size={size} /> };
}
if (allOf("breached")) {
return {
tone: "down",
message: t("pages.statusPages.statusBar.allBreached"),
icon: <ShieldAlert size={size} />,
};
}
if (allOf("maintenance")) {
return {
tone: "warn",
message: t("pages.statusPages.statusBar.allMaintenance"),
icon: <Wrench size={size} />,
};
}
if (allOf("down")) {
return {
tone: "down",
message: t("pages.statusPages.statusBar.allDown"),
icon: <CircleX size={size} />,
};
}
if (allOf("paused")) {
return {
tone: "warn",
message: t("pages.statusPages.statusBar.allPaused"),
icon: <PauseCircle size={size} />,
};
}
if (allOf("initializing")) {
return {
tone: "warn",
message: t("pages.statusPages.statusBar.allInitializing"),
icon: <Loader size={size} />,
};
}
if (someOf("breached") && someOf("down")) {
return {
tone: "down",
message: t("pages.statusPages.statusBar.breachedAndDown"),
icon: <ShieldAlert size={size} />,
};
}
if (someOf("breached")) {
return {
tone: "down",
message: t("pages.statusPages.statusBar.breached"),
icon: <ShieldAlert size={size} />,
};
}
if (someOf("maintenance") && someOf("down")) {
return {
tone: "down",
message: t("pages.statusPages.statusBar.maintenanceAndDown"),
icon: <Wrench size={size} />,
};
}
if (someOf("maintenance") && noneOf("down")) {
return {
tone: "warn",
message: t("pages.statusPages.statusBar.maintenance"),
icon: <Wrench size={size} />,
};
}
if (someOf("down")) {
return {
tone: "warn",
message: t("pages.statusPages.statusBar.degraded"),
icon: <AlertTriangle size={size} />,
};
}
if (someOf("paused")) {
return {
tone: "warn",
message: t("pages.statusPages.statusBar.partiallyPaused"),
icon: <PauseCircle size={size} />,
};
}
if (someOf("initializing")) {
return {
tone: "up",
message: t("pages.statusPages.statusBar.initializing"),
icon: <Loader size={size} />,
};
}
return {
tone: "warn",
message: t("pages.statusPages.statusBar.unknown"),
icon: <AlertTriangle size={size} />,
};
};
export const statusBadgeKey: Record<MonitorStatus, string> = {
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";
@@ -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",
});
};
@@ -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;
}
}
@@ -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<StatusPageTheme, ThemeVariants> = {
refined,
modern,
bold,
editorial,
};
export const toCssVars = (tokens: StatusPageThemeTokens): Record<string, string> => ({
"--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 } : {}),
});
+10
View File
@@ -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;
}
+3
View File
@@ -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(),
+55
View File
@@ -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.",