diff --git a/apps/framework-editor/app/components/HeaderFrameworks.tsx b/apps/framework-editor/app/components/HeaderFrameworks.tsx
index 716e99dd2b..c65d20ac1a 100644
--- a/apps/framework-editor/app/components/HeaderFrameworks.tsx
+++ b/apps/framework-editor/app/components/HeaderFrameworks.tsx
@@ -1,6 +1,7 @@
import { Skeleton } from '@trycompai/ui/skeleton';
import Link from 'next/link';
import { Suspense } from 'react';
+import { ThemeToggle } from './theme-toggle';
import { UserMenu } from './user-menu';
export async function Header() {
@@ -9,9 +10,12 @@ export async function Header() {
Framework Editor
- }>
-
-
+
+
+ }>
+
+
+
);
}
diff --git a/apps/framework-editor/app/components/theme-provider.tsx b/apps/framework-editor/app/components/theme-provider.tsx
new file mode 100644
index 0000000000..3370b01bc6
--- /dev/null
+++ b/apps/framework-editor/app/components/theme-provider.tsx
@@ -0,0 +1,14 @@
+'use client';
+
+import { ThemeProvider as NextThemesProvider } from 'next-themes';
+import * as React from 'react';
+
+type ThemeProviderProps = Parameters[0];
+
+export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/framework-editor/app/components/theme-toggle.test.tsx b/apps/framework-editor/app/components/theme-toggle.test.tsx
new file mode 100644
index 0000000000..cb9a86ad12
--- /dev/null
+++ b/apps/framework-editor/app/components/theme-toggle.test.tsx
@@ -0,0 +1,41 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const { setTheme, useThemeMock } = vi.hoisted(() => ({
+ setTheme: vi.fn(),
+ useThemeMock: vi.fn(),
+}));
+
+vi.mock('next-themes', () => ({ useTheme: useThemeMock }));
+vi.mock('@trycompai/ui', () => ({
+ Button: ({
+ children,
+ variant: _v,
+ size: _s,
+ ...props
+ }: { variant?: string; size?: string } & React.ComponentProps<'button'>) => (
+
+ ),
+}));
+
+import { ThemeToggle } from './theme-toggle';
+
+describe('ThemeToggle', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ useThemeMock.mockReturnValue({ resolvedTheme: 'light', setTheme });
+ });
+
+ it('switches to dark when currently light', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /toggle theme/i }));
+ expect(setTheme).toHaveBeenCalledWith('dark');
+ });
+
+ it('switches to light when currently dark', () => {
+ useThemeMock.mockReturnValue({ resolvedTheme: 'dark', setTheme });
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /toggle theme/i }));
+ expect(setTheme).toHaveBeenCalledWith('light');
+ });
+});
diff --git a/apps/framework-editor/app/components/theme-toggle.tsx b/apps/framework-editor/app/components/theme-toggle.tsx
new file mode 100644
index 0000000000..777bb1deac
--- /dev/null
+++ b/apps/framework-editor/app/components/theme-toggle.tsx
@@ -0,0 +1,30 @@
+'use client';
+
+import { Button } from '@trycompai/ui';
+import { Moon, Sun } from 'lucide-react';
+import { useTheme } from 'next-themes';
+import { useEffect, useState } from 'react';
+
+export function ThemeToggle() {
+ const { resolvedTheme, setTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+
+ // next-themes can't know the resolved theme during SSR; wait for mount before
+ // showing a theme-specific icon to avoid a hydration mismatch.
+ useEffect(() => setMounted(true), []);
+
+ const isDark = mounted && resolvedTheme === 'dark';
+
+ return (
+
+ );
+}
diff --git a/apps/framework-editor/app/layout.tsx b/apps/framework-editor/app/layout.tsx
index 3d5672c63f..46ee0df931 100644
--- a/apps/framework-editor/app/layout.tsx
+++ b/apps/framework-editor/app/layout.tsx
@@ -6,6 +6,7 @@ import { Toaster as SonnerToaster } from 'sonner';
import { headers } from 'next/headers';
import '../styles/globals.css';
import { Header } from './components/HeaderFrameworks';
+import { ThemeProvider } from './components/theme-provider';
import { auth } from './lib/auth';
import { isInternalUser } from './lib/utils';
@@ -25,14 +26,21 @@ export default async function RootLayout({ children }: { children: ReactNode })
isInternalUser(session.user.email);
return (
-
+
- {hasSession && }
-
- {children}
-
-
-
+
+ {hasSession && }
+
+ {children}
+
+
+
+
);
diff --git a/apps/framework-editor/package.json b/apps/framework-editor/package.json
index 9d1736d060..46c6a7754e 100644
--- a/apps/framework-editor/package.json
+++ b/apps/framework-editor/package.json
@@ -17,6 +17,7 @@
"framer-motion": "^12.23.9",
"lucide-react": "^1.7.0",
"next": "^16.2.0",
+ "next-themes": "^0.4.4",
"nuqs": "^2.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
diff --git a/apps/framework-editor/styles/globals.css b/apps/framework-editor/styles/globals.css
index 5cd105322f..77f5f2118a 100644
--- a/apps/framework-editor/styles/globals.css
+++ b/apps/framework-editor/styles/globals.css
@@ -1,6 +1,36 @@
@import '@trycompai/ui/globals.css';
@config '../tailwind.config.ts';
+/*
+ * Real dark-mode palette (FRAME-5). @trycompai/ui ships a `.dark` block whose
+ * values are identical to light, so without this, toggling the `dark` class
+ * changes nothing. Declared after the import (and unlayered) so it wins the
+ * cascade over the library defaults.
+ */
+.dark {
+ --background: 0 0% 7%;
+ --foreground: 0 0% 95%;
+ --card: 0 0% 9%;
+ --card-foreground: 0 0% 95%;
+ --popover: 0 0% 9%;
+ --popover-foreground: 0 0% 95%;
+ --primary: 165 70% 42%;
+ --primary-foreground: 0 0% 100%;
+ --secondary: 0 0% 15%;
+ --secondary-foreground: 0 0% 95%;
+ --muted: 0 0% 15%;
+ --muted-foreground: 0 0% 64%;
+ --accent: 0 0% 18%;
+ --accent-foreground: 0 0% 98%;
+ --destructive: 0 72% 51%;
+ --destructive-foreground: 0 0% 98%;
+ --warning: 45 93% 47%;
+ --warning-foreground: 26 83% 14%;
+ --border: 0 0% 18%;
+ --input: 0 0% 18%;
+ --ring: 165 70% 42%;
+}
+
@layer base {
* {
@apply border-border;
diff --git a/bun.lock b/bun.lock
index 3d06bfdff4..f70de19c1f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -417,6 +417,7 @@
"framer-motion": "^12.23.9",
"lucide-react": "^1.7.0",
"next": "^16.2.0",
+ "next-themes": "^0.4.4",
"nuqs": "^2.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",