From cc03bc2beedac44219c2bf190ad057b4ec421d63 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 12 Jun 2026 12:59:23 -0400 Subject: [PATCH] feat(framework-editor): add dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor inherited @trycompai/ui's `.dark` token block, whose values are identical to light — so there was no dark theme and no way to switch. - Add next-themes ThemeProvider (class strategy, system-aware, default light). - Add a Sun/Moon toggle in the header. - Define a real dark palette in the editor's globals (overrides the library's placeholder `.dark` tokens), scoped to framework-editor only. Closes FRAME-5 Co-Authored-By: Claude Opus 4.8 --- .../app/components/HeaderFrameworks.tsx | 10 +++-- .../app/components/theme-provider.tsx | 14 +++++++ .../app/components/theme-toggle.test.tsx | 41 +++++++++++++++++++ .../app/components/theme-toggle.tsx | 30 ++++++++++++++ apps/framework-editor/app/layout.tsx | 22 ++++++---- apps/framework-editor/package.json | 1 + apps/framework-editor/styles/globals.css | 30 ++++++++++++++ bun.lock | 1 + 8 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 apps/framework-editor/app/components/theme-provider.tsx create mode 100644 apps/framework-editor/app/components/theme-toggle.test.tsx create mode 100644 apps/framework-editor/app/components/theme-toggle.tsx 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",