From e5e3848e3ef980eaf647e17d1d7304c525792965 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:27:15 +0200 Subject: [PATCH] Add custom theme support for status pages (#2354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(status-page): add custom CSS gated by new custom-css plan limit - add "custom-css" boolean limit (team + scale only) and page.custom_css column - add sanitizeCustomCss / hasCustomCss / generatePageStyles to theme-store with tests - add updatePageCustomCss service verb (scope check, plan gate, audit) + tRPC endpoint - add FormCustomCss dashboard card with upgrade overlay when not eligible - render custom CSS after the theme on status pages so it wins the cascade, nulled out server-side when the workspace plan no longer includes the limit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011R5Sq8Hcmzw1n6QVpVGyNM * chore: retrigger CI (flaky subscriptions dispatcher test) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011R5Sq8Hcmzw1n6QVpVGyNM * feat(status-page): full css-var placeholder + custom css validation - generateCustomCssTemplate lists every THEME_VAR_NAMES entry pre-filled with the page's selected theme values; used as the textarea placeholder - validateCustomCss (theme-store) checks length, '<' breakouts, brace balance (comment/string aware) and presence of declaration blocks - wired into the dashboard form schema and UpdatePageCustomCssInput Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011R5Sq8Hcmzw1n6QVpVGyNM * fix(theme-store): linear-time css scan + align service test with validation - replace the lazy comment/string regexes with a single-pass scanner (CodeQL js/polynomial-redos on repeated unclosed "/*") and early-return on over-length input so the scan is bounded - update the service test: '<' in custom css is now rejected at the boundary instead of silently sanitized; add reject cases for breakout and unbalanced braces Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011R5Sq8Hcmzw1n6QVpVGyNM * chore: retrigger CI (flaky subscriptions dispatcher test) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011R5Sq8Hcmzw1n6QVpVGyNM * fix(theme-store): comment out unset vars in the custom css template An empty `--x: ;` from the saved template would override the global default with an empty value (custom css wins the cascade). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011R5Sq8Hcmzw1n6QVpVGyNM * chore: retrigger CI (flaky monitor quota test) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011R5Sq8Hcmzw1n6QVpVGyNM * wip: custom theme * fix(theme-store): linear declaration parser + drop duplicate migration - parseThemeVarsText: manual colon-split instead of a lazy regex that backtracks quadratically on hostile input (CodeQL js/polynomial-redos) - remove 0079_parched_sugar_man.sql — double-generate artifact not referenced by the drizzle journal Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011R5Sq8Hcmzw1n6QVpVGyNM * fix(subscriptions): deflake dispatcher entire-page assertion Package test suites run in parallel against the same seeded page, so an exact recipient count races with subscribers inserted by other suites. Assert against this suite's own subscriber emails instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011R5Sq8Hcmzw1n6QVpVGyNM * chore: changelog --------- Co-authored-by: Claude --- .../forms/status-page/form-custom-theme.tsx | 216 + .../components/forms/status-page/update.tsx | 18 + apps/dashboard/src/data/plans.ts | 4 + .../src/app/(status-page)/[domain]/layout.tsx | 10 +- .../status-page/floating-button.tsx | 16 +- .../changelog/status-page-custom-theme.png | Bin 0 -> 206752 bytes .../changelog/status-page-custom-theme.mdx | 16 + .../pages/docs/reference/status-page.mdx | 69 + .../src/content/pages/unrelated/pricing.mdx | 1 + deno.lock | 234 - packages/api/src/router/page.ts | 16 + packages/api/src/router/statusPage.ts | 10 + .../db/drizzle/0079_fixed_spencer_smythe.sql | 1 + packages/db/drizzle/meta/0079_snapshot.json | 4719 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/pages/page.ts | 5 + packages/db/src/schema/pages/validation.ts | 10 + packages/db/src/schema/plan/config.ts | 4 + packages/db/src/schema/plan/schema.ts | 1 + .../services/src/page/__tests__/page.test.ts | 136 +- packages/services/src/page/index.ts | 2 + packages/services/src/page/schemas.ts | 38 +- packages/services/src/page/update.ts | 37 + packages/subscriptions/src/dispatcher.test.ts | 10 +- packages/theme-store/package.json | 5 +- .../src/__tests__/custom-theme.test.ts | 204 + packages/theme-store/src/custom-theme.ts | 139 + packages/theme-store/src/index.ts | 23 + pnpm-lock.yaml | 6 + 29 files changed, 5713 insertions(+), 244 deletions(-) create mode 100644 apps/dashboard/src/components/forms/status-page/form-custom-theme.tsx create mode 100644 apps/web/public/assets/changelog/status-page-custom-theme.png create mode 100644 apps/web/src/content/pages/changelog/status-page-custom-theme.mdx create mode 100644 packages/db/drizzle/0079_fixed_spencer_smythe.sql create mode 100644 packages/db/drizzle/meta/0079_snapshot.json create mode 100644 packages/theme-store/src/__tests__/custom-theme.test.ts create mode 100644 packages/theme-store/src/custom-theme.ts diff --git a/apps/dashboard/src/components/forms/status-page/form-custom-theme.tsx b/apps/dashboard/src/components/forms/status-page/form-custom-theme.tsx new file mode 100644 index 00000000..dab6f3ce --- /dev/null +++ b/apps/dashboard/src/components/forms/status-page/form-custom-theme.tsx @@ -0,0 +1,216 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { + type CustomTheme, + formatThemeVars, + generateThemeVarsTemplate, + parseThemeVarsText, + THEMES, + type ThemeMode, +} from "@openstatus/theme-store"; +import { Button } from "@openstatus/ui/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormMessage, +} from "@openstatus/ui/components/ui/form"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@openstatus/ui/components/ui/tabs"; +import { Textarea } from "@openstatus/ui/components/ui/textarea"; +import { cn } from "@openstatus/ui/lib/utils"; +import { isTRPCClientError } from "@trpc/client"; +import { ChevronsDownUp, ChevronsUpDown, Lock } from "lucide-react"; +import { useState, useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +import { Link } from "@/components/common/link"; +import { + FormCard, + FormCardContent, + FormCardDescription, + FormCardFooter, + FormCardFooterInfo, + FormCardHeader, + FormCardSeparator, + FormCardTitle, + FormCardUpgrade, +} from "@/components/forms/form-card"; + +const MODES = ["light", "dark"] as const; + +const modeText = z.string().superRefine((value, ctx) => { + for (const message of parseThemeVarsText(value).errors) { + ctx.addIssue({ code: "custom", message }); + } +}); + +const schema = z.object({ light: modeText, dark: modeText }); + +type FormValues = z.infer; + +export function FormCustomTheme({ + defaultValue, + onSubmit, + locked, + themeKey, +}: { + defaultValue?: CustomTheme | null; + onSubmit: (values: { customTheme: CustomTheme }) => Promise; + locked?: boolean; + themeKey?: string; +}) { + const theme = THEMES[themeKey ?? "default"] ?? THEMES.default; + const [mode, setMode] = useState("light"); + const [extended, setExtended] = useState(false); + const [isPending, startTransition] = useTransition(); + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + light: formatThemeVars(defaultValue?.light), + dark: formatThemeVars(defaultValue?.dark), + }, + }); + + function submitAction(values: FormValues) { + if (isPending) return; + + startTransition(async () => { + try { + const promise = onSubmit({ + customTheme: { + light: parseThemeVarsText(values.light).vars, + dark: parseThemeVarsText(values.dark).vars, + }, + }); + toast.promise(promise, { + loading: "Saving...", + success: "Saved", + error: (error) => { + if (isTRPCClientError(error)) { + return error.message; + } + return "Failed to save"; + }, + }); + await promise; + } catch (error) { + console.error(error); + } + }); + } + + return ( +
+ { + // jump to the first tab with a validation error + const errored = MODES.find((m) => errors[m]); + if (errored) setMode(errored); + })} + > + + {locked ? : null} + + Custom Theme + + Override the selected theme's CSS variables with your own + values for light and dark mode. + + + + + setMode(v as ThemeMode)}> +
+ + Light + Dark + + +
+ {MODES.map((m) => ( + + ( + + +