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:
@@ -32,15 +32,26 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Enforce dark scheme as per our theme */}
|
||||
<ColorSchemeScript defaultColorScheme="dark" />
|
||||
{/* Auto-detect system preference for color scheme */}
|
||||
<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 */}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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" />
|
||||
</head>
|
||||
<body className={forum.variable} suppressHydrationWarning>
|
||||
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||
<MantineProvider theme={theme} defaultColorScheme="auto">
|
||||
<Notifications />
|
||||
<AppLayout>{children}</AppLayout>
|
||||
</MantineProvider>
|
||||
|
||||
12
app/theme.ts
12
app/theme.ts
@@ -56,12 +56,16 @@ export const theme = createTheme({
|
||||
radius: 'md',
|
||||
withBorder: true,
|
||||
},
|
||||
styles: {
|
||||
styles: (theme) => ({
|
||||
root: {
|
||||
backgroundColor: '#212529', // gray.8
|
||||
borderColor: '#495057', // gray.6
|
||||
backgroundColor: theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[7]
|
||||
: theme.white,
|
||||
borderColor: theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[5]
|
||||
: theme.colors.gray[3],
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
TextInput: {
|
||||
defaultProps: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { IconMessageCircle, IconEdit, IconChartBubbleFilled } from '@tabler/icon
|
||||
import { useSelector } from '@xstate/react';
|
||||
import { useAppMachine } from '@/hooks/useAppMachine';
|
||||
import { UserMenu } from '@/components/UserMenu';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
|
||||
export function DesktopSidebar() {
|
||||
const actor = useAppMachine();
|
||||
@@ -111,6 +112,9 @@ export function DesktopSidebar() {
|
||||
|
||||
<Divider my="md" color="#373A40" />
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* User Menu - styled like other nav items */}
|
||||
<UserMenu showLabel={true} />
|
||||
|
||||
|
||||
35
components/ThemeToggle.tsx
Normal file
35
components/ThemeToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Text,
|
||||
} from '@react-three/drei';
|
||||
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 * as THREE from 'three';
|
||||
|
||||
@@ -30,17 +30,24 @@ interface LinkData {
|
||||
function Node({
|
||||
node,
|
||||
isFocused,
|
||||
onNodeClick
|
||||
onNodeClick,
|
||||
isDark
|
||||
}: {
|
||||
node: NodeData;
|
||||
isFocused: boolean;
|
||||
onNodeClick: (node: NodeData) => void;
|
||||
isDark: boolean;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const isExpanded = isFocused || hovered;
|
||||
const scale = isFocused ? 2.5 : 1;
|
||||
|
||||
// Theme-aware colors
|
||||
const nodeColor = isDark ? '#e9ecef' : '#495057';
|
||||
const focusColor = '#4dabf7';
|
||||
const hoverColor = '#90c0ff';
|
||||
|
||||
return (
|
||||
<mesh
|
||||
position={node.coords_3d}
|
||||
@@ -57,8 +64,8 @@ function Node({
|
||||
>
|
||||
<sphereGeometry args={[0.1, 32, 32]} />
|
||||
<meshStandardMaterial
|
||||
color={isFocused ? '#4dabf7' : (hovered ? '#90c0ff' : '#e9ecef')}
|
||||
emissive={isFocused ? '#4dabf7' : (hovered ? '#90c0ff' : '#e9ecef')}
|
||||
color={isFocused ? focusColor : (hovered ? hoverColor : nodeColor)}
|
||||
emissive={isFocused ? focusColor : (hovered ? hoverColor : nodeColor)}
|
||||
emissiveIntensity={isFocused ? 0.8 : (hovered ? 0.5 : 0.1)}
|
||||
/>
|
||||
{/* Show title on hover or focus */}
|
||||
@@ -66,7 +73,7 @@ function Node({
|
||||
<Text
|
||||
position={[0, 0.3 / scale, 0]}
|
||||
fontSize={0.1 / scale}
|
||||
color="white"
|
||||
color={isDark ? 'white' : 'black'}
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
>
|
||||
@@ -82,6 +89,9 @@ export function ThoughtGalaxy() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const colorScheme = useComputedColorScheme('light');
|
||||
const isDark = colorScheme === 'dark';
|
||||
|
||||
const [nodes, setNodes] = useState<NodeData[]>([]);
|
||||
const [links, setLinks] = useState<LinkData[]>([]);
|
||||
const [selectedNode, setSelectedNode] = useState<NodeData | null>(null);
|
||||
@@ -353,8 +363,9 @@ export function ThoughtGalaxy() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ambientLight intensity={0.5} />
|
||||
<pointLight position={[10, 10, 10]} intensity={1} />
|
||||
{/* Theme-aware lighting */}
|
||||
<ambientLight intensity={isDark ? 0.3 : 0.5} />
|
||||
<pointLight position={[10, 10, 10]} intensity={isDark ? 0.8 : 1} />
|
||||
<CameraControls ref={cameraControlsRef} />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
@@ -366,15 +377,16 @@ export function ThoughtGalaxy() {
|
||||
node={node}
|
||||
isFocused={selectedNodeId === node.id || selectedNode?.id === node.id}
|
||||
onNodeClick={handleNodeClick}
|
||||
isDark={isDark}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Render all links */}
|
||||
{/* Render all links - theme-aware colors */}
|
||||
{linkLines.map((line, i) => (
|
||||
<Line
|
||||
key={i}
|
||||
points={[line.start, line.end]}
|
||||
color="#495057" // gray
|
||||
color={isDark ? '#495057' : '#adb5bd'}
|
||||
lineWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
7
public/favicon-dark.svg
Normal file
7
public/favicon-dark.svg
Normal 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
7
public/favicon-light.svg
Normal 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 |
126
tests/playwright/theme.spec.ts
Normal file
126
tests/playwright/theme.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user