feat: Add dark/light mode theme switching with dynamic favicons

Implemented comprehensive dark/light mode support throughout the app:

- Added ColorSchemeScript to layout for auto-detection of system preference
- Updated MantineProvider to use 'auto' color scheme (respects system)
- Updated theme.ts with dynamic Paper component styles based on color scheme
- Created ThemeToggle component with sun/moon icons
- Added toggle to desktop sidebar navigation
- Created theme-specific favicons (favicon-light.svg, favicon-dark.svg)
- Made ThoughtGalaxy 3D visualization theme-aware:
  - Dynamic node colors based on theme
  - Theme-aware lighting intensity
  - Theme-aware link colors
  - Theme-aware text labels
- Added comprehensive Playwright tests for theme functionality
- Theme preference persists via localStorage

Tested manually with Playwright MCP:
-  Theme toggle switches between light and dark modes
-  Theme persists across page reloads
-  Both modes render correctly with appropriate colors
-  Icons change based on current theme (sun/moon)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-09 22:04:26 +00:00
parent 482ec9fff8
commit 9bf16fefaf
8 changed files with 222 additions and 16 deletions

View File

@@ -32,15 +32,26 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head> <head>
{/* Enforce dark scheme as per our theme */} {/* Auto-detect system preference for color scheme */}
<ColorSchemeScript defaultColorScheme="dark" /> <ColorSchemeScript defaultColorScheme="auto" />
{/* Dynamic favicons based on theme */}
<link
rel="icon"
href="/favicon-light.svg"
media="(prefers-color-scheme: light)"
/>
<link
rel="icon"
href="/favicon-dark.svg"
media="(prefers-color-scheme: dark)"
/>
{/* Load Zalando Sans from Google Fonts */} {/* Load Zalando Sans from Google Fonts */}
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Zalando+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Zalando+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head> </head>
<body className={forum.variable} suppressHydrationWarning> <body className={forum.variable} suppressHydrationWarning>
<MantineProvider theme={theme} defaultColorScheme="dark"> <MantineProvider theme={theme} defaultColorScheme="auto">
<Notifications /> <Notifications />
<AppLayout>{children}</AppLayout> <AppLayout>{children}</AppLayout>
</MantineProvider> </MantineProvider>

View File

@@ -56,12 +56,16 @@ export const theme = createTheme({
radius: 'md', radius: 'md',
withBorder: true, withBorder: true,
}, },
styles: { styles: (theme) => ({
root: { root: {
backgroundColor: '#212529', // gray.8 backgroundColor: theme.colorScheme === 'dark'
borderColor: '#495057', // gray.6 ? theme.colors.dark[7]
: theme.white,
borderColor: theme.colorScheme === 'dark'
? theme.colors.dark[5]
: theme.colors.gray[3],
}, },
}, }),
}, },
TextInput: { TextInput: {
defaultProps: { defaultProps: {

View File

@@ -13,6 +13,7 @@ import { IconMessageCircle, IconEdit, IconChartBubbleFilled } from '@tabler/icon
import { useSelector } from '@xstate/react'; import { useSelector } from '@xstate/react';
import { useAppMachine } from '@/hooks/useAppMachine'; import { useAppMachine } from '@/hooks/useAppMachine';
import { UserMenu } from '@/components/UserMenu'; import { UserMenu } from '@/components/UserMenu';
import { ThemeToggle } from '@/components/ThemeToggle';
export function DesktopSidebar() { export function DesktopSidebar() {
const actor = useAppMachine(); const actor = useAppMachine();
@@ -111,6 +112,9 @@ export function DesktopSidebar() {
<Divider my="md" color="#373A40" /> <Divider my="md" color="#373A40" />
{/* Theme Toggle */}
<ThemeToggle />
{/* User Menu - styled like other nav items */} {/* User Menu - styled like other nav items */}
<UserMenu showLabel={true} /> <UserMenu showLabel={true} />

View File

@@ -0,0 +1,35 @@
'use client';
import { ActionIcon, useMantineColorScheme, useComputedColorScheme } from '@mantine/core';
import { IconSun, IconMoon } from '@tabler/icons-react';
/**
* ThemeToggle Component
*
* Toggles between light and dark color schemes.
* Uses Mantine's built-in color scheme hooks for state management and persistence.
*/
export function ThemeToggle() {
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme('light');
const toggleColorScheme = () => {
setColorScheme(computedColorScheme === 'dark' ? 'light' : 'dark');
};
return (
<ActionIcon
onClick={toggleColorScheme}
variant="subtle"
size="lg"
aria-label="Toggle color scheme"
title={`Switch to ${computedColorScheme === 'dark' ? 'light' : 'dark'} mode`}
>
{computedColorScheme === 'dark' ? (
<IconSun size={20} />
) : (
<IconMoon size={20} />
)}
</ActionIcon>
);
}

View File

@@ -7,7 +7,7 @@ import {
Text, Text,
} from '@react-three/drei'; } from '@react-three/drei';
import { Suspense, useEffect, useRef, useState } from 'react'; import { Suspense, useEffect, useRef, useState } from 'react';
import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor } from '@mantine/core'; import { Stack, Text as MantineText, Paper, Title, Box, CloseButton, Group, Anchor, useComputedColorScheme } from '@mantine/core';
import { useRouter, usePathname, useSearchParams } from 'next/navigation'; import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import * as THREE from 'three'; import * as THREE from 'three';
@@ -30,17 +30,24 @@ interface LinkData {
function Node({ function Node({
node, node,
isFocused, isFocused,
onNodeClick onNodeClick,
isDark
}: { }: {
node: NodeData; node: NodeData;
isFocused: boolean; isFocused: boolean;
onNodeClick: (node: NodeData) => void; onNodeClick: (node: NodeData) => void;
isDark: boolean;
}) { }) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const isExpanded = isFocused || hovered; const isExpanded = isFocused || hovered;
const scale = isFocused ? 2.5 : 1; const scale = isFocused ? 2.5 : 1;
// Theme-aware colors
const nodeColor = isDark ? '#e9ecef' : '#495057';
const focusColor = '#4dabf7';
const hoverColor = '#90c0ff';
return ( return (
<mesh <mesh
position={node.coords_3d} position={node.coords_3d}
@@ -57,8 +64,8 @@ function Node({
> >
<sphereGeometry args={[0.1, 32, 32]} /> <sphereGeometry args={[0.1, 32, 32]} />
<meshStandardMaterial <meshStandardMaterial
color={isFocused ? '#4dabf7' : (hovered ? '#90c0ff' : '#e9ecef')} color={isFocused ? focusColor : (hovered ? hoverColor : nodeColor)}
emissive={isFocused ? '#4dabf7' : (hovered ? '#90c0ff' : '#e9ecef')} emissive={isFocused ? focusColor : (hovered ? hoverColor : nodeColor)}
emissiveIntensity={isFocused ? 0.8 : (hovered ? 0.5 : 0.1)} emissiveIntensity={isFocused ? 0.8 : (hovered ? 0.5 : 0.1)}
/> />
{/* Show title on hover or focus */} {/* Show title on hover or focus */}
@@ -66,7 +73,7 @@ function Node({
<Text <Text
position={[0, 0.3 / scale, 0]} position={[0, 0.3 / scale, 0]}
fontSize={0.1 / scale} fontSize={0.1 / scale}
color="white" color={isDark ? 'white' : 'black'}
anchorX="center" anchorX="center"
anchorY="middle" anchorY="middle"
> >
@@ -82,6 +89,9 @@ export function ThoughtGalaxy() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const colorScheme = useComputedColorScheme('light');
const isDark = colorScheme === 'dark';
const [nodes, setNodes] = useState<NodeData[]>([]); const [nodes, setNodes] = useState<NodeData[]>([]);
const [links, setLinks] = useState<LinkData[]>([]); const [links, setLinks] = useState<LinkData[]>([]);
const [selectedNode, setSelectedNode] = useState<NodeData | null>(null); const [selectedNode, setSelectedNode] = useState<NodeData | null>(null);
@@ -353,8 +363,9 @@ export function ThoughtGalaxy() {
} }
}} }}
> >
<ambientLight intensity={0.5} /> {/* Theme-aware lighting */}
<pointLight position={[10, 10, 10]} intensity={1} /> <ambientLight intensity={isDark ? 0.3 : 0.5} />
<pointLight position={[10, 10, 10]} intensity={isDark ? 0.8 : 1} />
<CameraControls ref={cameraControlsRef} /> <CameraControls ref={cameraControlsRef} />
<Suspense fallback={null}> <Suspense fallback={null}>
@@ -366,15 +377,16 @@ export function ThoughtGalaxy() {
node={node} node={node}
isFocused={selectedNodeId === node.id || selectedNode?.id === node.id} isFocused={selectedNodeId === node.id || selectedNode?.id === node.id}
onNodeClick={handleNodeClick} onNodeClick={handleNodeClick}
isDark={isDark}
/> />
))} ))}
{/* Render all links */} {/* Render all links - theme-aware colors */}
{linkLines.map((line, i) => ( {linkLines.map((line, i) => (
<Line <Line
key={i} key={i}
points={[line.start, line.end]} points={[line.start, line.end]}
color="#495057" // gray color={isDark ? '#495057' : '#adb5bd'}
lineWidth={1} lineWidth={1}
/> />
))} ))}

7
public/favicon-dark.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Light icon for dark mode -->
<circle cx="50" cy="50" r="40" fill="#f8f9fa" />
<circle cx="35" cy="45" r="5" fill="#212529" />
<circle cx="65" cy="45" r="5" fill="#212529" />
<path d="M 30 65 Q 50 75 70 65" stroke="#212529" stroke-width="3" fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 340 B

7
public/favicon-light.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Dark icon for light mode -->
<circle cx="50" cy="50" r="40" fill="#212529" />
<circle cx="35" cy="45" r="5" fill="#ffffff" />
<circle cx="65" cy="45" r="5" fill="#ffffff" />
<path d="M 30 65 Q 50 75 70 65" stroke="#ffffff" stroke-width="3" fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 340 B

View File

@@ -0,0 +1,126 @@
import { test, expect } from './fixtures';
test.describe('Theme Switching', () => {
test('user can toggle between light and dark mode', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Get initial theme
const initialTheme = await page.evaluate(() => {
return document.documentElement.getAttribute('data-mantine-color-scheme');
});
// Click theme toggle button
await page.click('[aria-label="Toggle color scheme"]');
await page.waitForTimeout(500); // Wait for theme transition
// Check theme changed
const newTheme = await page.evaluate(() => {
return document.documentElement.getAttribute('data-mantine-color-scheme');
});
expect(newTheme).not.toBe(initialTheme);
expect(['light', 'dark']).toContain(newTheme);
});
test('theme preference persists across page refreshes', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Set to a specific mode (light)
await page.evaluate(() => {
document.documentElement.setAttribute('data-mantine-color-scheme', 'light');
window.localStorage.setItem('mantine-color-scheme-value', 'light');
});
// Get the theme before reload
const themeBeforeReload = await page.evaluate(() => {
return window.localStorage.getItem('mantine-color-scheme-value');
});
// Refresh page
await page.reload();
await page.waitForLoadState('networkidle');
// Check theme persisted
const persistedTheme = await page.evaluate(() => {
return document.documentElement.getAttribute('data-mantine-color-scheme');
});
expect(persistedTheme).toBe(themeBeforeReload);
});
test('theme toggle icon changes based on current theme', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// The button should be visible
const toggleButton = page.locator('[aria-label="Toggle color scheme"]');
await expect(toggleButton).toBeVisible();
// Icon should exist (either sun or moon)
const icon = toggleButton.locator('img, svg').first();
await expect(icon).toBeVisible();
});
});
test.describe('Theme Visual Appearance', () => {
test('light mode has light background', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Force light mode
await page.evaluate(() => {
document.documentElement.setAttribute('data-mantine-color-scheme', 'light');
window.localStorage.setItem('mantine-color-scheme-value', 'light');
});
// Reload to apply theme
await page.reload();
await page.waitForLoadState('networkidle');
// Check that we're in light mode
const colorScheme = await page.evaluate(() => {
return document.documentElement.getAttribute('data-mantine-color-scheme');
});
expect(colorScheme).toBe('light');
});
test('dark mode has dark background', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Force dark mode
await page.evaluate(() => {
document.documentElement.setAttribute('data-mantine-color-scheme', 'dark');
window.localStorage.setItem('mantine-color-scheme-value', 'dark');
});
// Reload to apply theme
await page.reload();
await page.waitForLoadState('networkidle');
// Check that we're in dark mode
const colorScheme = await page.evaluate(() => {
return document.documentElement.getAttribute('data-mantine-color-scheme');
});
expect(colorScheme).toBe('dark');
});
});
test.describe('Theme Auto-Detection', () => {
test('respects system color scheme preference when set to auto', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// The app should detect and apply a theme (either light or dark)
const appliedTheme = await page.evaluate(() => {
return document.documentElement.getAttribute('data-mantine-color-scheme');
});
// Should be either light or dark, not null
expect(['light', 'dark']).toContain(appliedTheme);
});
});