-Pick a theme from the store or override any CSS variable, separately for light and dark. Point status.yourdomain.com at it; the "Powered by" line goes away on Scale.
+Pick a theme from the store or override any CSS variable, separately for light and dark. Point status.yourdomain.com at it, and even white label it to drop the "Powered by" line.
- [Theme Store](https://themes.openstatus.dev)
- [Custom theme variables](/docs/reference/status-page#custom-theme)
diff --git a/packages/theme-store/README.md b/packages/theme-store/README.md
index fd69ab76..f44215b7 100644
--- a/packages/theme-store/README.md
+++ b/packages/theme-store/README.md
@@ -28,7 +28,9 @@ Want to contribute a theme?
We only support themes via GitHub contributions to keep a certain version control. You can:
-- Configure your [themes.openstatus.dev](https://themes.openstatus.dev/?b=true) and copy the configuration
+- Configure your theme at [themes.openstatus.dev](https://themes.openstatus.dev/?b=true) and copy it as a TypeScript file, JSON or CSS
+- Let your agent design it: **Copy prompt** in the builder hands your agent the current configuration and the JSON shape to reply with; **Paste** applies the reply (JSON, the `.ts` theme file, or `:root { } / .dark { }` CSS) to the preview
+- Preview the builder theme on a full status page with **Test it** on the explorer page
- Directly test by running it locally
### 1. Run the project
diff --git a/packages/theme-store/src/__tests__/theme-io.test.ts b/packages/theme-store/src/__tests__/theme-io.test.ts
new file mode 100644
index 00000000..cc2033f1
--- /dev/null
+++ b/packages/theme-store/src/__tests__/theme-io.test.ts
@@ -0,0 +1,216 @@
+import { readdirSync, readFileSync } from "node:fs";
+
+import { expect } from "@std/expect";
+import { describe, test } from "@std/testing/bdd";
+
+import {
+ parseThemeInput,
+ serializeTheme,
+ THEME_KEYS,
+ THEMES,
+ type Theme,
+} from "../index";
+
+function expectOk(result: ReturnType) {
+ if (!result.ok) throw new Error(result.error);
+ return result;
+}
+
+describe("parseThemeInput", () => {
+ test("round-trips every registered theme through each export format", () => {
+ for (const key of THEME_KEYS) {
+ const theme = THEMES[key];
+ for (const format of ["ts", "json", "css"] as const) {
+ const result = expectOk(parseThemeInput(serializeTheme(theme, format)));
+ expect(result.definition).toEqual({
+ light: theme.light,
+ dark: theme.dark,
+ });
+ expect(result.warnings).toEqual([]);
+ if (format !== "css") {
+ expect(result.info).toEqual({
+ id: theme.id,
+ name: theme.name,
+ author: theme.author,
+ });
+ }
+ }
+ }
+ });
+
+ test("parses the theme source files as they are checked in", () => {
+ const dir = new URL("../", import.meta.url);
+ const skip = [
+ "custom-theme.ts",
+ "index.ts",
+ "theme-io.ts",
+ "types.ts",
+ "utils.ts",
+ ];
+ const parsedIds: string[] = [];
+ for (const file of readdirSync(dir)) {
+ if (!file.endsWith(".ts") || skip.includes(file)) continue;
+ const source = readFileSync(new URL(file, dir), "utf8");
+ // one literal per `export const`; spreads (default-rounded) aren't parseable
+ for (const chunk of source.split(/^export const /m).slice(1)) {
+ if (chunk.includes("...")) continue;
+ const result = expectOk(parseThemeInput(chunk));
+ const theme = THEMES[result.info.id ?? ""];
+ expect(
+ theme,
+ `${file}: unregistered id ${result.info.id}`,
+ ).toBeDefined();
+ expect(result.definition).toEqual({
+ light: theme.light,
+ dark: theme.dark,
+ });
+ expect(result.warnings).toEqual([]);
+ parsedIds.push(theme.id);
+ }
+ }
+ expect(parsedIds).toContain("default");
+ expect(parsedIds.length).toBeGreaterThanOrEqual(THEME_KEYS.length - 1);
+ });
+
+ test("accepts a bare { light, dark } definition", () => {
+ const result = expectOk(
+ parseThemeInput(
+ '{ "light": { "--primary": "red" }, "dark": { "--primary": "pink" } }',
+ ),
+ );
+ expect(result.info).toEqual({});
+ expect(result.partial).toBe(false);
+ expect(result.definition).toEqual({
+ light: { "--primary": "red" },
+ dark: { "--primary": "pink" },
+ });
+ });
+
+ test("accepts an agent reply wrapped in a markdown code fence", () => {
+ const result = expectOk(
+ parseThemeInput(
+ 'Here you go:\n```json\n{ "id": "nord", "name": "Nord", "light": { "--primary": "#5e81ac" }, "dark": {} }\n```\nLet me know!',
+ ),
+ );
+ expect(result.info.id).toBe("nord");
+ expect(result.definition.light).toEqual({ "--primary": "#5e81ac" });
+ });
+
+ test("ignores a leading comment that contains a brace", () => {
+ const result = expectOk(
+ parseThemeInput(
+ '/** see {@link Theme} */\n// shape: { light, dark }\nexport const X = { light: { "--primary": "red" }, dark: {} } as const satisfies Theme;',
+ ),
+ );
+ expect(result.definition.light).toEqual({ "--primary": "red" });
+ });
+
+ test("keeps comment-like characters inside strings", () => {
+ const result = expectOk(
+ parseThemeInput(
+ '{ "author": { "name": "x", "url": "https://x.dev/*" }, "light": { "--primary": "red" }, "dark": {} }',
+ ),
+ );
+ expect(result.info.author?.url).toBe("https://x.dev/*");
+ });
+
+ test("matches CSS selectors exactly, not as prefixes", () => {
+ const result = expectOk(
+ parseThemeInput(
+ ".light-theme { --primary: blue; }\n.light { --primary: red; }\n.dark-mode { --primary: blue; }\n.dark { --primary: pink; }",
+ ),
+ );
+ expect(result.definition).toEqual({
+ light: { "--primary": "red" },
+ dark: { "--primary": "pink" },
+ });
+ });
+
+ test("applies bare declarations and flat objects to the fallback mode", () => {
+ const declarations = expectOk(
+ parseThemeInput("--primary: red;\n--radius: 0.5rem", "dark"),
+ );
+ expect(declarations.partial).toBe(true);
+ expect(declarations.definition).toEqual({
+ light: {},
+ dark: { "--primary": "red", "--radius": "0.5rem" },
+ });
+
+ const flat = expectOk(parseThemeInput('{ "--primary": "red" }', "dark"));
+ expect(flat.partial).toBe(true);
+ expect(flat.definition).toEqual({
+ light: {},
+ dark: { "--primary": "red" },
+ });
+ });
+
+ test("merges a lone mode instead of wiping the other one", () => {
+ const css = expectOk(parseThemeInput(":root {\n --primary: red;\n}"));
+ expect(css.partial).toBe(true);
+ expect(css.definition).toEqual({ light: { "--primary": "red" }, dark: {} });
+
+ const json = expectOk(
+ parseThemeInput('{ "dark": { "--primary": "red" } }'),
+ );
+ expect(json.partial).toBe(true);
+ expect(json.definition).toEqual({
+ light: {},
+ dark: { "--primary": "red" },
+ });
+ });
+
+ test("reads :root / .dark CSS blocks", () => {
+ const result = expectOk(
+ parseThemeInput(
+ ":root {\n --primary: oklch(0.6 0.1 250);\n}\n.dark {\n --primary: oklch(0.8 0.1 250);\n}",
+ ),
+ );
+ expect(result.partial).toBe(false);
+ expect(result.definition).toEqual({
+ light: { "--primary": "oklch(0.6 0.1 250)" },
+ dark: { "--primary": "oklch(0.8 0.1 250)" },
+ });
+ });
+
+ test("drops unknown vars and unsafe values with a warning", () => {
+ const result = expectOk(
+ parseThemeInput(
+ '{ "light": { "--primary": "red", "--nope": "blue", "--info": "" } }',
+ ),
+ );
+ expect(result.definition.light).toEqual({ "--primary": "red" });
+ expect(result.warnings).toHaveLength(2);
+ expect(result.warnings.join()).toContain('Unknown CSS variable "--nope"');
+ });
+
+ test("fails when nothing usable was found", () => {
+ expect(parseThemeInput("").ok).toBe(false);
+ expect(parseThemeInput("hello world").ok).toBe(false);
+ expect(parseThemeInput('{ "name": "x" }').ok).toBe(false);
+ expect(parseThemeInput("{ light: { --nope: red } }").ok).toBe(false);
+ expect(parseThemeInput("{ light: [1, 2] }").ok).toBe(false);
+ });
+});
+
+describe("serializeTheme", () => {
+ const theme = {
+ id: "my-theme",
+ name: "My Theme",
+ author: { name: "@me", url: "https://example.com" },
+ light: { "--primary": "red" },
+ dark: { "--primary": "pink" },
+ } satisfies Theme;
+
+ test("ts names the export after the id", () => {
+ const ts = serializeTheme(theme, "ts");
+ expect(ts).toContain("export const MY_THEME = {");
+ expect(ts).toContain("packages/theme-store/src/my-theme.ts");
+ expect(ts).toContain("} as const satisfies Theme;");
+ });
+
+ test("css emits :root and .dark blocks", () => {
+ expect(serializeTheme(theme, "css")).toBe(
+ ":root {\n --primary: red;\n}\n.dark {\n --primary: pink;\n}",
+ );
+ });
+});
diff --git a/packages/theme-store/src/custom-theme.ts b/packages/theme-store/src/custom-theme.ts
index edf3e67a..e9bd3052 100644
--- a/packages/theme-store/src/custom-theme.ts
+++ b/packages/theme-store/src/custom-theme.ts
@@ -29,7 +29,7 @@ export type CustomThemeValidation =
type LooseThemeVars = Record;
type LooseCustomTheme = { light?: LooseThemeVars; dark?: LooseThemeVars };
-function validateVarEntry(name: string, value: string): string[] {
+export function validateVarEntry(name: string, value: string): string[] {
const errors: string[] = [];
if (!THEME_VAR_NAME_SET.has(name)) {
errors.push(`Unknown CSS variable "${name}".`);
diff --git a/packages/theme-store/src/index.ts b/packages/theme-store/src/index.ts
index e4bd03a7..72006ce6 100644
--- a/packages/theme-store/src/index.ts
+++ b/packages/theme-store/src/index.ts
@@ -1,4 +1,5 @@
export * from "./custom-theme";
+export * from "./theme-io";
export * from "./types";
import {
type CustomTheme,
diff --git a/packages/theme-store/src/theme-io.ts b/packages/theme-store/src/theme-io.ts
new file mode 100644
index 00000000..8cdca0bf
--- /dev/null
+++ b/packages/theme-store/src/theme-io.ts
@@ -0,0 +1,333 @@
+import { parseThemeVarsText, validateVarEntry } from "./custom-theme";
+import type {
+ Theme,
+ ThemeDefinition,
+ ThemeInfo,
+ ThemeMode,
+ ThemeVarName,
+ ThemeVars,
+} from "./types";
+
+export type ThemeExportFormat = "ts" | "json" | "css";
+
+export type ParsedThemeInput = {
+ info: Partial;
+ definition: ThemeDefinition;
+ /** Merge into the current theme instead of replacing it (bare declarations, a lone CSS block). */
+ partial: boolean;
+ /** Dropped vars / values — the rest of the input still applied. */
+ warnings: string[];
+};
+
+export type ParseThemeInputResult =
+ | ({ ok: true } & ParsedThemeInput)
+ | { ok: false; error: string };
+
+/**
+ * Parses anything a builder, an agent or a theme file hands over: a `Theme`
+ * (or `{ light, dark }`) as JSON or as a TS/JS object literal, CSS with
+ * `:root { }` / `.dark { }` blocks, or bare `--name: value;` declarations
+ * (applied to `fallbackMode`).
+ */
+export function parseThemeInput(
+ text: string,
+ fallbackMode: ThemeMode = "light",
+): ParseThemeInputResult {
+ const source = text.trim();
+ if (source.length === 0) return { ok: false, error: "Nothing to paste." };
+
+ const light = cssBlock(source, [":root", ".light"]);
+ const dark = cssBlock(source, [".dark"]);
+ const parsed =
+ light !== undefined || dark !== undefined
+ ? parseCssBlocks(light, dark)
+ : source.includes("{")
+ ? parseObjectLiteral(source, fallbackMode)
+ : parseDeclarations(source, fallbackMode);
+
+ if (!parsed.ok) return parsed;
+
+ const hasVars =
+ Object.keys(parsed.definition.light).length > 0 ||
+ Object.keys(parsed.definition.dark).length > 0;
+ if (!hasVars) {
+ const detail = parsed.warnings[0] ? ` ${parsed.warnings[0]}` : "";
+ return { ok: false, error: `No supported CSS variables found.${detail}` };
+ }
+ return parsed;
+}
+
+function parseDeclarations(
+ source: string,
+ mode: ThemeMode,
+): ParseThemeInputResult {
+ const { vars, errors } = parseThemeVarsText(source);
+ const definition: ThemeDefinition = { light: {}, dark: {} };
+ definition[mode] = vars;
+ return { ok: true, info: {}, definition, partial: true, warnings: errors };
+}
+
+/** Body of the first `selector { … }`; indexOf instead of a regex (CodeQL js/polynomial-redos). */
+function cssBlock(source: string, selectors: string[]) {
+ for (const selector of selectors) {
+ let from = 0;
+ let start = source.indexOf(selector, from);
+ while (start !== -1) {
+ from = start + selector.length;
+ const open = source.indexOf("{", from);
+ if (open === -1) break;
+ // `.light-theme {` is not `.light {`
+ if (source.slice(from, open).trim().length === 0) {
+ const close = source.indexOf("}", open);
+ return close === -1 ? undefined : source.slice(open + 1, close);
+ }
+ start = source.indexOf(selector, from);
+ }
+ }
+ return undefined;
+}
+
+function parseCssBlocks(
+ light: string | undefined,
+ dark: string | undefined,
+): ParseThemeInputResult {
+ const lightResult = parseThemeVarsText(light ?? "");
+ const darkResult = parseThemeVarsText(dark ?? "");
+ return {
+ ok: true,
+ info: {},
+ definition: { light: lightResult.vars, dark: darkResult.vars },
+ // a lone block must not wipe the other mode
+ partial: light === undefined || dark === undefined,
+ warnings: [...lightResult.errors, ...darkResult.errors],
+ };
+}
+
+function parseObjectLiteral(
+ source: string,
+ fallbackMode: ThemeMode,
+): ParseThemeInputResult {
+ // `import type { Theme }` carries a brace before the literal; everything
+ // else ahead of it (comments, fences, prose) is skipped by objectStart
+ const stripped = source.replace(/^\s*import\b[^\n]*$/gm, "");
+ const from = objectStart(stripped);
+ if (from === -1) {
+ return { ok: false, error: "Expected an object with light / dark vars." };
+ }
+ const json = toJson(stripped.slice(from).replace(/\bas const\b/g, ""));
+ const start = json.indexOf("{");
+ const end = json.lastIndexOf("}");
+ if (start === -1 || end <= start) {
+ return { ok: false, error: "Expected an object with light / dark vars." };
+ }
+
+ let value: unknown;
+ try {
+ value = JSON.parse(json.slice(start, end + 1));
+ } catch {
+ return {
+ ok: false,
+ error:
+ "Could not parse the configuration. Paste the JSON or the theme file as is.",
+ };
+ }
+ if (!isRecord(value)) {
+ return { ok: false, error: "Expected an object with light / dark vars." };
+ }
+
+ const warnings: string[] = [];
+ const definition: ThemeDefinition = { light: {}, dark: {} };
+ let partial = false;
+
+ if (isRecord(value.light) || isRecord(value.dark)) {
+ // `{ light }` alone must not wipe dark (and vice versa)
+ partial = value.light === undefined || value.dark === undefined;
+ for (const mode of ["light", "dark"] as const) {
+ const vars = value[mode];
+ if (vars === undefined) continue;
+ if (!isRecord(vars)) {
+ warnings.push(`"${mode}" must be an object of CSS variables.`);
+ continue;
+ }
+ definition[mode] = pickVars(vars, warnings);
+ }
+ } else if (Object.keys(value).some((key) => key.startsWith("--"))) {
+ definition[fallbackMode] = pickVars(value, warnings);
+ partial = true;
+ } else {
+ return { ok: false, error: "Expected an object with light / dark vars." };
+ }
+
+ const info: Partial = {};
+ if (typeof value.id === "string" && value.id.trim())
+ info.id = value.id.trim();
+ if (typeof value.name === "string" && value.name.trim()) {
+ info.name = value.name.trim();
+ }
+ if (
+ isRecord(value.author) &&
+ typeof value.author.name === "string" &&
+ typeof value.author.url === "string"
+ ) {
+ info.author = { name: value.author.name, url: value.author.url };
+ }
+
+ return { ok: true, info, definition, partial, warnings };
+}
+
+function pickVars(
+ input: Record,
+ warnings: string[],
+): ThemeVars {
+ const vars: ThemeVars = {};
+ for (const [name, raw] of Object.entries(input)) {
+ if (typeof raw !== "string") {
+ warnings.push(`Value of "${name}" must be a string.`);
+ continue;
+ }
+ const errors = validateVarEntry(name, raw);
+ if (errors.length > 0) {
+ warnings.push(...errors);
+ continue;
+ }
+ // safe: validateVarEntry checked membership in THEME_VAR_NAMES
+ vars[name as ThemeVarName] = raw.trim();
+ }
+ return vars;
+}
+
+/** Index of the first `{` outside a comment; the body itself is left to the string-aware toJson. */
+function objectStart(source: string) {
+ let i = 0;
+ while (i < source.length) {
+ if (source.startsWith("//", i)) {
+ const end = source.indexOf("\n", i);
+ if (end === -1) return -1;
+ i = end + 1;
+ } else if (source.startsWith("/*", i)) {
+ const end = source.indexOf("*/", i + 2);
+ if (end === -1) return -1;
+ i = end + 2;
+ } else if (source.charAt(i) === "{") {
+ return i;
+ } else {
+ i++;
+ }
+ }
+ return -1;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/**
+ * Rewrites a TS/JS object literal into strict JSON: strips comments, quotes
+ * bare keys, normalizes quotes and drops trailing commas. String-aware, so
+ * `//` inside an author url survives.
+ */
+function toJson(source: string): string {
+ let out = "";
+ let i = 0;
+ while (i < source.length) {
+ const ch = source.charAt(i);
+
+ if (ch === '"' || ch === "'" || ch === "`") {
+ let j = i + 1;
+ let str = "";
+ while (j < source.length && source[j] !== ch) {
+ if (source[j] === "\\") {
+ str += source[j + 1] ?? "";
+ j += 2;
+ continue;
+ }
+ str += source.charAt(j);
+ j++;
+ }
+ out += JSON.stringify(str);
+ i = j + 1;
+ continue;
+ }
+
+ if (ch === "/" && source[i + 1] === "/") {
+ while (i < source.length && source[i] !== "\n") i++;
+ continue;
+ }
+ if (ch === "/" && source[i + 1] === "*") {
+ const end = source.indexOf("*/", i + 2);
+ i = end === -1 ? source.length : end + 2;
+ continue;
+ }
+
+ if (/[A-Za-z_$]/.test(ch)) {
+ let j = i;
+ while (j < source.length && /[\w$]/.test(source.charAt(j))) j++;
+ const word = source.slice(i, j);
+ let k = j;
+ while (k < source.length && /\s/.test(source.charAt(k))) k++;
+ if (source[k] === ":") {
+ out += `${JSON.stringify(word)}:`;
+ i = k + 1;
+ } else {
+ out += word;
+ i = j;
+ }
+ continue;
+ }
+
+ // comments are already gone from `out`, so a trailing comma is whatever
+ // precedes the closing bracket
+ if (ch === "}" || ch === "]") out = out.replace(/,\s*$/, "");
+
+ out += ch;
+ i++;
+ }
+ return out;
+}
+
+/** Serializes a theme for the clipboard: theme file, JSON or CSS blocks. */
+export function serializeTheme(theme: Theme, format: ThemeExportFormat) {
+ switch (format) {
+ case "json":
+ return JSON.stringify(theme, null, 2);
+ case "css":
+ return `:root {\n${formatBlock(theme.light)}\n}\n.dark {\n${formatBlock(theme.dark)}\n}`;
+ case "ts":
+ return [
+ `// packages/theme-store/src/${theme.id}.ts`,
+ `import type { Theme } from "./types";`,
+ "",
+ `export const ${constName(theme.id)} = {`,
+ ` id: ${JSON.stringify(theme.id)},`,
+ ` name: ${JSON.stringify(theme.name)},`,
+ ` author: { name: ${JSON.stringify(theme.author.name)}, url: ${JSON.stringify(theme.author.url)} },`,
+ ` light: {\n${formatObject(theme.light)}\n },`,
+ ` dark: {\n${formatObject(theme.dark)}\n },`,
+ "} as const satisfies Theme;",
+ ].join("\n");
+ }
+}
+
+function constName(id: string) {
+ const base = id
+ .toUpperCase()
+ .replace(/[^A-Z0-9]+/g, "_")
+ .replace(/^_|_$/g, "")
+ .replace(/_?THEME$/, "");
+ return `${base || "MY"}_THEME`;
+}
+
+function formatBlock(vars: ThemeVars) {
+ return Object.entries(vars)
+ .map(([name, value]) => ` ${name}: ${value};`)
+ .join("\n");
+}
+
+function formatObject(vars: ThemeVars) {
+ return Object.entries(vars)
+ .map(
+ ([name, value]) =>
+ ` ${JSON.stringify(name)}: ${JSON.stringify(value)},`,
+ )
+ .join("\n");
+}
--
2.51.2