diff --git a/apps/web/src/components/CustomThemeDialog.tsx b/apps/web/src/components/CustomThemeDialog.tsx
new file mode 100644
index 00000000..c64401a8
--- /dev/null
+++ b/apps/web/src/components/CustomThemeDialog.tsx
@@ -0,0 +1,279 @@
+import { LinkIcon, PaletteIcon, SparklesIcon } from "lucide-react";
+import { useCallback, useRef, useState } from "react";
+import {
+ type CustomThemeData,
+ getStoredCustomTheme,
+ isTweakcnURL,
+ parseThemeInput,
+ setStoredCustomTheme,
+} from "../lib/customTheme";
+import { cn } from "../lib/utils";
+import { Button } from "./ui/button";
+import {
+ Dialog,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogPanel,
+ DialogPopup,
+ DialogTitle,
+} from "./ui/dialog";
+
+// ---------------------------------------------------------------------------
+// Color Preview Swatch
+// ---------------------------------------------------------------------------
+
+function ColorSwatch({
+ label,
+ bg,
+ fg,
+}: {
+ label: string;
+ bg: string | undefined;
+ fg: string | undefined;
+}) {
+ if (!bg) return null;
+ return (
+
+
+ {fg ? (
+
+ A
+
+ ) : null}
+
+
{label}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Preview Panel
+// ---------------------------------------------------------------------------
+
+function ThemePreview({ theme }: { theme: CustomThemeData | null }) {
+ if (!theme || Object.keys(theme.light).length === 0) return null;
+
+ const colors = [
+ { label: "Background", bg: theme.light.background, fg: theme.light.foreground },
+ { label: "Primary", bg: theme.light.primary, fg: theme.light["primary-foreground"] },
+ { label: "Secondary", bg: theme.light.secondary, fg: theme.light["secondary-foreground"] },
+ { label: "Accent", bg: theme.light.accent, fg: theme.light["accent-foreground"] },
+ { label: "Muted", bg: theme.light.muted, fg: theme.light["muted-foreground"] },
+ { label: "Card", bg: theme.light.card, fg: theme.light["card-foreground"] },
+ {
+ label: "Destructive",
+ bg: theme.light.destructive,
+ fg: theme.light["destructive-foreground"],
+ },
+ { label: "Border", bg: theme.light.border, fg: undefined },
+ ];
+
+ const radius = theme.light.radius;
+ const fontSans = theme.light["font-sans"];
+ const fontMono = theme.light["font-mono"];
+
+ return (
+
+
+
+ Preview {theme.name ? `- ${theme.name}` : ""}
+
+
+ {/* Color swatches */}
+
+ {colors.map((c) => (
+
+ ))}
+
+
+ {/* Design tokens */}
+
+ {radius ? Radius: {radius} : null}
+ {fontSans ? (
+ Font: {fontSans.split(",")[0]?.trim()}
+ ) : null}
+ {fontMono ? (
+ Mono: {fontMono.split(",")[0]?.trim()}
+ ) : null}
+
+
+ {/* Variable count */}
+
+ {Object.keys(theme.light).length} light + {Object.keys(theme.dark).length} dark variables
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Main Dialog
+// ---------------------------------------------------------------------------
+
+export function CustomThemeDialog({
+ open,
+ onOpenChange,
+ onApply,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onApply: (theme: CustomThemeData) => void;
+}) {
+ const [input, setInput] = useState("");
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [preview, setPreview] = useState(null);
+ const textareaRef = useRef(null);
+
+ // Pre-populate with existing custom theme
+ const handleOpen = useCallback(() => {
+ const existing = getStoredCustomTheme();
+ if (existing) {
+ setPreview(existing);
+ }
+ setError(null);
+ setInput("");
+ }, []);
+
+ // Parse input on change (debounced feel via paste handling)
+ const handleParse = useCallback(async () => {
+ const trimmed = input.trim();
+ if (!trimmed) {
+ setPreview(null);
+ setError(null);
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const theme = await parseThemeInput(trimmed);
+ setPreview(theme);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to parse theme");
+ setPreview(null);
+ } finally {
+ setLoading(false);
+ }
+ }, [input]);
+
+ const handleApply = useCallback(() => {
+ if (!preview) return;
+ setStoredCustomTheme(preview);
+ onApply(preview);
+ onOpenChange(false);
+ setInput("");
+ setPreview(null);
+ setError(null);
+ }, [preview, onApply, onOpenChange]);
+
+ const isUrl = isTweakcnURL(input.trim());
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts
index 79291476..916a58ef 100644
--- a/apps/web/src/hooks/useTheme.ts
+++ b/apps/web/src/hooks/useTheme.ts
@@ -1,4 +1,12 @@
import { useCallback, useEffect, useSyncExternalStore } from "react";
+import {
+ applyCustomTheme,
+ applyFontOverride,
+ applyRadiusOverride,
+ getStoredCustomTheme,
+ initCustomTheme,
+ removeCustomTheme,
+} from "../lib/customTheme";
type Theme = "light" | "dark" | "system";
type ColorTheme =
@@ -27,6 +35,7 @@ export const COLOR_THEMES: { id: ColorTheme; label: string }[] = [
{ id: "carbon", label: "Carbon" },
{ id: "vapor", label: "Vapor" },
{ id: "cathedral-circuit", label: "Cathedral Circuit" },
+ { id: "custom", label: "Custom" },
];
export const FONT_FAMILIES: { id: FontFamily; label: string }[] = [
@@ -142,6 +151,9 @@ function syncDesktopTheme(theme: Theme) {
});
}
+// Initialize custom theme + overrides on module load
+initCustomTheme();
+
// Apply immediately on module load to prevent flash
applyTheme(getStored());
@@ -238,3 +250,5 @@ export function useTheme() {
setFontFamily,
} as const;
}
+
+export type { Theme, ColorTheme };
diff --git a/apps/web/src/lib/customTheme.ts b/apps/web/src/lib/customTheme.ts
new file mode 100644
index 00000000..e64b3b3f
--- /dev/null
+++ b/apps/web/src/lib/customTheme.ts
@@ -0,0 +1,569 @@
+/**
+ * Custom theme support — parse, apply, and persist themes from tweakcn.com
+ *
+ * Supports three input formats:
+ * 1. Raw CSS (from tweakcn "Copy CSS" or any shadcn-compatible CSS)
+ * 2. JSON (shadcn registry format from tweakcn API)
+ * 3. tweakcn.com URLs (fetched via their CORS-enabled API)
+ */
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+/** CSS variable names we map from imported themes into our theme system. */
+const SUPPORTED_COLOR_VARS = [
+ "background",
+ "foreground",
+ "card",
+ "card-foreground",
+ "popover",
+ "popover-foreground",
+ "primary",
+ "primary-foreground",
+ "secondary",
+ "secondary-foreground",
+ "muted",
+ "muted-foreground",
+ "accent",
+ "accent-foreground",
+ "destructive",
+ "destructive-foreground",
+ "border",
+ "input",
+ "ring",
+ "info",
+ "info-foreground",
+ "success",
+ "success-foreground",
+ "warning",
+ "warning-foreground",
+] as const;
+
+const SUPPORTED_DESIGN_VARS = ["radius"] as const;
+
+const SUPPORTED_FONT_VARS = ["font-sans", "font-serif", "font-mono"] as const;
+
+const SUPPORTED_SHADOW_VARS = [
+ "shadow-2xs",
+ "shadow-xs",
+ "shadow-sm",
+ "shadow",
+ "shadow-md",
+ "shadow-lg",
+ "shadow-xl",
+ "shadow-2xl",
+] as const;
+
+const ALL_SUPPORTED_VARS = new Set([
+ ...SUPPORTED_COLOR_VARS,
+ ...SUPPORTED_DESIGN_VARS,
+ ...SUPPORTED_FONT_VARS,
+ ...SUPPORTED_SHADOW_VARS,
+]);
+
+const CUSTOM_THEME_STORAGE_KEY = "okcode:custom-theme";
+const CUSTOM_THEME_STYLE_ID = "okcode-custom-theme-style";
+const CUSTOM_THEME_FONT_LINK_ID = "okcode-custom-theme-fonts";
+const RADIUS_OVERRIDE_KEY = "okcode:radius-override";
+const FONT_OVERRIDE_KEY = "okcode:font-override";
+
+/** System-bundled fonts that don't need to be loaded from Google Fonts. */
+const SYSTEM_FONTS = new Set([
+ "system-ui",
+ "-apple-system",
+ "blinkmacsystemfont",
+ "segoe ui",
+ "roboto",
+ "helvetica neue",
+ "arial",
+ "sans-serif",
+ "serif",
+ "monospace",
+ "sf mono",
+ "sfmono-regular",
+ "consolas",
+ "liberation mono",
+ "menlo",
+ "courier new",
+ "dm sans",
+ "georgia",
+ "times new roman",
+ "times",
+ "ui-monospace",
+ "ui-sans-serif",
+ "ui-serif",
+]);
+
+// ---------------------------------------------------------------------------
+// Environment Guard
+// ---------------------------------------------------------------------------
+
+/** Returns true when a full browser DOM is available (not a Node/test stub). */
+function hasDom(): boolean {
+ return (
+ typeof document !== "undefined" &&
+ typeof document.getElementById === "function" &&
+ typeof document.createElement === "function"
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export interface CustomThemeData {
+ name?: string | undefined;
+ light: Record;
+ dark: Record;
+}
+
+// ---------------------------------------------------------------------------
+// CSS Parsing
+// ---------------------------------------------------------------------------
+
+/** Extract content between matched braces starting after an opening `{`. */
+function extractBraceContent(css: string, startAfterBrace: number): string {
+ let depth = 1;
+ let i = startAfterBrace;
+ while (i < css.length && depth > 0) {
+ if (css[i] === "{") depth++;
+ else if (css[i] === "}") depth--;
+ i++;
+ }
+ return css.substring(startAfterBrace, i - 1);
+}
+
+/** Pull all `--name: value;` declarations out of a CSS block. */
+function extractVariables(block: string): Record {
+ const vars: Record = {};
+ // Match CSS custom property declarations, handling values that can contain
+ // parentheses (like oklch(...), hsl(...), calc(...), rgba(...))
+ const regex = /--([\w-]+)\s*:\s*([^;]+);/g;
+ let match: RegExpExecArray | null;
+ while ((match = regex.exec(block)) !== null) {
+ const name = match[1]?.trim();
+ const value = match[2]?.trim();
+ if (!name || !value) continue;
+ if (ALL_SUPPORTED_VARS.has(name)) {
+ vars[name] = value;
+ }
+ }
+ return vars;
+}
+
+/** Filter to only include variables we support. */
+function filterSupported(vars: Record): Record {
+ const out: Record = {};
+ for (const [k, v] of Object.entries(vars)) {
+ if (ALL_SUPPORTED_VARS.has(k)) {
+ out[k] = v;
+ }
+ }
+ return out;
+}
+
+/**
+ * Parse CSS text that contains `:root` and `.dark` blocks.
+ * Handles both bare selectors and selectors nested inside `@layer base { }`.
+ */
+export function parseThemeCSS(css: string): CustomThemeData {
+ // Strip comments
+ const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, "");
+
+ const light: Record = {};
+ const dark: Record = {};
+
+ // Find :root blocks (may or may not be inside @layer base)
+ const rootRegex = /:root\s*\{/g;
+ let match: RegExpExecArray | null;
+ while ((match = rootRegex.exec(cleaned)) !== null) {
+ const start = match.index + match[0].length;
+ const content = extractBraceContent(cleaned, start);
+ Object.assign(light, extractVariables(content));
+ }
+
+ // Find .dark blocks
+ const darkRegex = /\.dark\s*\{/g;
+ while ((match = darkRegex.exec(cleaned)) !== null) {
+ const start = match.index + match[0].length;
+ const content = extractBraceContent(cleaned, start);
+ Object.assign(dark, extractVariables(content));
+ }
+
+ // Fallback: if no blocks found, try parsing the whole thing as a list of vars
+ // (user may have just pasted the variable declarations)
+ if (Object.keys(light).length === 0 && Object.keys(dark).length === 0) {
+ const vars = extractVariables(cleaned);
+ Object.assign(light, vars);
+ // Copy light vars to dark as a reasonable default
+ Object.assign(dark, vars);
+ }
+
+ return { light, dark };
+}
+
+// ---------------------------------------------------------------------------
+// tweakcn JSON Parsing
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse the JSON format returned by tweakcn's `/r/themes/[id]` API endpoint
+ * (shadcn registry format).
+ */
+export function parseTweakcnJSON(json: unknown): CustomThemeData {
+ if (!json || typeof json !== "object") {
+ throw new Error("Invalid theme JSON");
+ }
+
+ const obj = json as Record;
+ const name = typeof obj.name === "string" ? obj.name : undefined;
+
+ const cssVars = obj.cssVars as Record> | undefined;
+ if (!cssVars || typeof cssVars !== "object") {
+ throw new Error('Theme JSON missing "cssVars" object');
+ }
+
+ const light = filterSupported({
+ ...(cssVars.theme ?? {}),
+ ...(cssVars.light ?? {}),
+ });
+
+ const dark = filterSupported({
+ ...(cssVars.theme ?? {}),
+ ...(cssVars.dark ?? {}),
+ });
+
+ if (Object.keys(light).length === 0 && Object.keys(dark).length === 0) {
+ throw new Error("No supported theme variables found in JSON");
+ }
+
+ return { name, light, dark };
+}
+
+// ---------------------------------------------------------------------------
+// URL Handling
+// ---------------------------------------------------------------------------
+
+const TWEAKCN_URL_PATTERNS = [
+ /^https?:\/\/(?:www\.)?tweakcn\.com\/r\/themes\/([^/?#]+)/,
+ /^https?:\/\/(?:www\.)?tweakcn\.com\/themes\/([^/?#]+)/,
+];
+
+/** Check if a string looks like a tweakcn.com URL. */
+export function isTweakcnURL(input: string): boolean {
+ const trimmed = input.trim();
+ return TWEAKCN_URL_PATTERNS.some((re) => re.test(trimmed));
+}
+
+/** Extract the theme ID from a tweakcn URL. */
+function extractTweakcnThemeId(url: string): string | null {
+ const trimmed = url.trim();
+ for (const re of TWEAKCN_URL_PATTERNS) {
+ const m = trimmed.match(re);
+ if (m?.[1]) return m[1];
+ }
+ return null;
+}
+
+/** Fetch a theme from tweakcn's API (has `Access-Control-Allow-Origin: *`). */
+export async function fetchTweakcnTheme(url: string): Promise {
+ const themeId = extractTweakcnThemeId(url);
+ if (!themeId) {
+ throw new Error("Could not extract theme ID from URL");
+ }
+
+ const apiUrl = `https://tweakcn.com/r/themes/${encodeURIComponent(themeId)}`;
+ const res = await fetch(apiUrl);
+ if (!res.ok) {
+ throw new Error(`Failed to fetch theme: ${res.status} ${res.statusText}`);
+ }
+
+ const json: unknown = await res.json();
+ return parseTweakcnJSON(json);
+}
+
+// ---------------------------------------------------------------------------
+// Smart Input Parser
+// ---------------------------------------------------------------------------
+
+/**
+ * Parse any user input — CSS text, JSON, or a tweakcn URL — and return a
+ * `CustomThemeData` object.
+ */
+export async function parseThemeInput(input: string): Promise {
+ const trimmed = input.trim();
+
+ // 1. tweakcn URL
+ if (isTweakcnURL(trimmed)) {
+ return fetchTweakcnTheme(trimmed);
+ }
+
+ // 2. JSON (starts with { or [)
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
+ try {
+ const json: unknown = JSON.parse(trimmed);
+ return parseTweakcnJSON(json);
+ } catch {
+ // Fall through to CSS parsing
+ }
+ }
+
+ // 3. CSS
+ const parsed = parseThemeCSS(trimmed);
+ if (Object.keys(parsed.light).length === 0 && Object.keys(parsed.dark).length === 0) {
+ throw new Error("No theme variables found. Paste CSS from tweakcn.com or a tweakcn theme URL.");
+ }
+
+ return parsed;
+}
+
+// ---------------------------------------------------------------------------
+// Google Font Loading
+// ---------------------------------------------------------------------------
+
+/** Extract font family names that need to be loaded from Google Fonts. */
+function extractGoogleFonts(theme: CustomThemeData): string[] {
+ const fonts = new Set();
+
+ for (const mode of [theme.light, theme.dark]) {
+ for (const key of SUPPORTED_FONT_VARS) {
+ const value = mode[key];
+ if (!value) continue;
+
+ // Parse font stack: "Plus Jakarta Sans, sans-serif" → ["Plus Jakarta Sans"]
+ const families = value.split(",").map((f) => f.trim().replace(/^["']|["']$/g, ""));
+ for (const family of families) {
+ if (family && !SYSTEM_FONTS.has(family.toLowerCase())) {
+ fonts.add(family);
+ }
+ }
+ }
+ }
+
+ return Array.from(fonts);
+}
+
+/** Inject a tag to load Google Fonts for the custom theme. */
+function loadGoogleFonts(fonts: string[]): void {
+ if (!hasDom()) return;
+ // Remove existing link
+ document.getElementById(CUSTOM_THEME_FONT_LINK_ID)?.remove();
+
+ if (fonts.length === 0) return;
+
+ const families = fonts.map((f) => `family=${f.replace(/\s+/g, "+")}:wght@300..800`).join("&");
+ const url = `https://fonts.googleapis.com/css2?${families}&display=swap`;
+
+ const link = document.createElement("link");
+ link.id = CUSTOM_THEME_FONT_LINK_ID;
+ link.rel = "stylesheet";
+ link.href = url;
+ document.head.appendChild(link);
+}
+
+/** Remove the Google Fonts tag. */
+function unloadGoogleFonts(): void {
+ if (!hasDom()) return;
+ document.getElementById(CUSTOM_THEME_FONT_LINK_ID)?.remove();
+}
+
+// ---------------------------------------------------------------------------
+// Theme Application
+// ---------------------------------------------------------------------------
+
+/** Build the CSS text for the custom theme `