Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
529 changes: 529 additions & 0 deletions .claude/doc/dark_light_mode/theme-architecture-integration.md

Large diffs are not rendered by default.

1,564 changes: 1,564 additions & 0 deletions .claude/doc/dark_light_mode/theme-testing-strategy.md

Large diffs are not rendered by default.

1,020 changes: 1,020 additions & 0 deletions .claude/doc/dark_light_mode/theme-toggle-component-design.md

Large diffs are not rendered by default.

656 changes: 656 additions & 0 deletions .claude/sessions/context_session_dark_light_mode.md

Large diffs are not rendered by default.

27 changes: 25 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,31 @@ Located at: `app/features/conversation/hooks/useConversation.tsx:37`
- CSS variables enabled for theming
- Components auto-imported to `@/components/ui`

### Theme Management

The application supports light and dark themes with the following features:

**Implementation:**
- **Library**: next-themes v0.2.1
- **Pattern**: Two-state toggle (Light ↔ Dark)
- **Default**: System preference (respects OS-level dark mode)
- **Persistence**: localStorage (key: "theme")
- **Location**: Theme toggle in navbar (top-right)

**Architecture:**
- **Provider**: `components/theme-provider.tsx` (wraps app at root)
- **Toggle**: `components/theme-toggle.tsx` (simple button component)
- **Integration**: Infrastructure-level concern (not a feature)

**Usage:**
Users can toggle between light and dark modes by clicking the Sun/Moon icon in the navbar. The preference persists across sessions.

**Developer Notes:**
- Theme is client-only state (no server synchronization)
- Uses next-themes directly (no custom wrappers)
- CSS variables defined in `app/globals.css`
- Tailwind configured with `darkMode: ['class']`

## Adding New Features

### Adding a New Tool
Expand All @@ -187,8 +212,6 @@ npx shadcn-ui@latest add [component-name]

Components are automatically configured for the project's path aliases.

## Sub-Agent Workflow

## Rules
- After a plan mode phase you should create a `.claude/sessions/context_session_{feature_name}.md` with the definition of the plan
- Before you do any work, MUST view files in `.claude/sessions/context_session_{feature_name}.md` file and `.claude/doc/{feature_name}/*` files to get the full context (feature_name being the id of the session we are operate, if file doesnt exist, then create one)
Expand Down
18 changes: 13 additions & 5 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { GeistSans } from "geist/font/sans";
import { Toaster } from "sonner";
import { cn } from "@/lib/utils";
import { Navbar } from "@/components/navbar";
import { ThemeProvider } from "@/components/theme-provider";

export const metadata = {
title: "AI SDK Streaming Preview",
Expand Down Expand Up @@ -31,12 +32,19 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<head></head>
<body className={cn(GeistSans.className, "antialiased dark")}>
<Toaster position="top-center" richColors />
<Navbar />
{children}
<body className={cn(GeistSans.className, "antialiased")}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange={false}
>
<Toaster position="top-center" richColors />
<Navbar />
{children}
</ThemeProvider>
</body>
</html>
);
Expand Down
199 changes: 199 additions & 0 deletions components/__tests__/theme-toggle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// ABOUTME: Unit tests for ThemeToggle component
// ABOUTME: Tests theme switching, accessibility, persistence, and edge cases

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeToggle } from './theme-toggle';

// Mock next-themes
const mockSetTheme = vi.fn();
const mockTheme = vi.fn(() => 'light');

vi.mock('next-themes', () => ({
useTheme: () => ({
theme: mockTheme(),
setTheme: mockSetTheme,
}),
}));

describe('ThemeToggle Component', () => {
let user: ReturnType<typeof userEvent.setup>;

beforeEach(() => {
user = userEvent.setup();
localStorage.clear();
mockSetTheme.mockClear();
mockTheme.mockReturnValue('light');
});

describe('Core Scenario 1: Rendering', () => {
it('should render theme toggle button', () => {
render(<ThemeToggle />);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});

it('should have proper accessibility attributes', () => {
render(<ThemeToggle />);
const button = screen.getByRole('button');

expect(button).toHaveAttribute('aria-label');
expect(button.getAttribute('aria-label')).toMatch(/switch to (dark|light) mode/i);
});

it('should be keyboard focusable', () => {
render(<ThemeToggle />);
const button = screen.getByRole('button');

button.focus();
expect(button).toHaveFocus();
});
});

describe('Core Scenario 2: Toggle Functionality', () => {
it('should toggle from light to dark theme on click', async () => {
mockTheme.mockReturnValue('light');
render(<ThemeToggle />);
const button = screen.getByRole('button');

await user.click(button);

expect(mockSetTheme).toHaveBeenCalledWith('dark');
});

it('should toggle from dark to light theme on click', async () => {
mockTheme.mockReturnValue('dark');
render(<ThemeToggle />);
const button = screen.getByRole('button');

await user.click(button);

expect(mockSetTheme).toHaveBeenCalledWith('light');
});

it('should call setTheme function when button is clicked', async () => {
render(<ThemeToggle />);
const button = screen.getByRole('button');

await user.click(button);

expect(mockSetTheme).toHaveBeenCalledTimes(1);
});
});

describe('Core Scenario 3: Keyboard Navigation', () => {
it('should toggle theme on Enter key', async () => {
render(<ThemeToggle />);
const button = screen.getByRole('button');

button.focus();
await user.keyboard('{Enter}');

expect(mockSetTheme).toHaveBeenCalledWith('dark');
});

it('should toggle theme on Space key', async () => {
render(<ThemeToggle />);
const button = screen.getByRole('button');

button.focus();
await user.keyboard(' ');

expect(mockSetTheme).toHaveBeenCalledWith('dark');
});

it('should maintain focus after toggle', async () => {
render(<ThemeToggle />);
const button = screen.getByRole('button');

await user.click(button);

expect(button).toHaveFocus();
});
});

describe('Core Scenario 4: Accessibility', () => {
it('should update aria-label based on current theme', () => {
mockTheme.mockReturnValue('light');
const { rerender } = render(<ThemeToggle />);
const button = screen.getByRole('button');

expect(button.getAttribute('aria-label')).toBe('Switch to dark mode');

mockTheme.mockReturnValue('dark');
rerender(<ThemeToggle />);

expect(button.getAttribute('aria-label')).toBe('Switch to light mode');
});

it('should render both Sun and Moon icons', () => {
render(<ThemeToggle />);
const button = screen.getByRole('button');

// Both icons should be in the DOM (one visible via CSS)
const svgElements = button.querySelectorAll('svg');
expect(svgElements.length).toBe(2);
});
});

describe('Core Scenario 5: Edge Cases', () => {
it('should handle rapid successive toggles', async () => {
render(<ThemeToggle />);
const button = screen.getByRole('button');

// Rapid clicks
await user.click(button);
await user.click(button);
await user.click(button);

// Should have called setTheme 3 times
expect(mockSetTheme).toHaveBeenCalledTimes(3);
});

it('should handle system theme preference', () => {
mockTheme.mockReturnValue('system');
render(<ThemeToggle />);
const button = screen.getByRole('button');

// Should not throw error with system theme
expect(button).toBeInTheDocument();
});

it('should handle undefined theme gracefully', () => {
mockTheme.mockReturnValue(undefined);

// Should render without errors
expect(() => render(<ThemeToggle />)).not.toThrow();
});
});

describe('Integration: Component Behavior', () => {
it('should render ghost variant button', () => {
render(<ThemeToggle />);
const button = screen.getByRole('button');

// Check for ghost variant class (from shadcn/ui button)
expect(button.className).toMatch(/ghost/);
});

it('should have responsive sizing classes', () => {
render(<ThemeToggle />);
const button = screen.getByRole('button');

// Check for responsive classes
expect(button.className).toMatch(/h-8 w-8/);
expect(button.className).toMatch(/md:h-10 md:w-10/);
});

it('should apply icon size classes correctly', () => {
render(<ThemeToggle />);
const button = screen.getByRole('button');
const icons = button.querySelectorAll('svg');

icons.forEach((icon) => {
expect(icon.className).toMatch(/h-\[1\.2rem\] w-\[1\.2rem\]/);
});
});
});
});
7 changes: 6 additions & 1 deletion components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
import { Button } from "./ui/button";
import { GitIcon, VercelIcon } from "../app/features/conversation/components/icons";
import Link from "next/link";
import { ThemeToggle } from "./theme-toggle";

export const Navbar = () => {
return (
<div className="p-2 flex flex-row gap-2 justify-between">
<div className="p-2 flex flex-row gap-2 justify-between items-center">
{/* Left section - reserved for logo/brand */}
<div></div>

{/* Right section - theme toggle */}
<ThemeToggle />
</div>
);
};
11 changes: 11 additions & 0 deletions components/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// ABOUTME: Wrapper component for next-themes ThemeProvider with app-specific configuration
// ABOUTME: Provides theme context to entire application with system preference detection
"use client"

import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
29 changes: 29 additions & 0 deletions components/theme-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// ABOUTME: Two-state toggle button for switching between light and dark themes
// ABOUTME: Uses Sun/Moon icons with smooth rotate/scale animations on theme change
"use client"

import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"

export function ThemeToggle() {
const { theme, setTheme } = useTheme()

const toggleTheme = () => {
setTheme(theme === "light" ? "dark" : "light")
}

return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="h-8 w-8 md:h-10 md:w-10"
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} mode`}
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
)
}