diff --git a/app/layout.tsx b/app/layout.tsx index 6f93285..3ddfe79 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -32,15 +32,26 @@ export default function RootLayout({ return ( - {/* Enforce dark scheme as per our theme */} - + {/* Auto-detect system preference for color scheme */} + + {/* Dynamic favicons based on theme */} + + {/* Load Zalando Sans from Google Fonts */} - + {children} diff --git a/app/theme.ts b/app/theme.ts index bfd6019..27e1e94 100644 --- a/app/theme.ts +++ b/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: { diff --git a/components/Navigation/DesktopSidebar.tsx b/components/Navigation/DesktopSidebar.tsx index 0df34da..17ba9a4 100644 --- a/components/Navigation/DesktopSidebar.tsx +++ b/components/Navigation/DesktopSidebar.tsx @@ -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() { + {/* Theme Toggle */} + + {/* User Menu - styled like other nav items */} diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx new file mode 100644 index 0000000..751a8ce --- /dev/null +++ b/components/ThemeToggle.tsx @@ -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 ( + + {computedColorScheme === 'dark' ? ( + + ) : ( + + )} + + ); +} diff --git a/components/ThoughtGalaxy.tsx b/components/ThoughtGalaxy.tsx index 5d34dc4..72f1c6e 100644 --- a/components/ThoughtGalaxy.tsx +++ b/components/ThoughtGalaxy.tsx @@ -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 ( {/* Show title on hover or focus */} @@ -66,7 +73,7 @@ function Node({ @@ -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([]); const [links, setLinks] = useState([]); const [selectedNode, setSelectedNode] = useState(null); @@ -353,8 +363,9 @@ export function ThoughtGalaxy() { } }} > - - + {/* Theme-aware lighting */} + + @@ -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) => ( ))} diff --git a/public/favicon-dark.svg b/public/favicon-dark.svg new file mode 100644 index 0000000..ab8abcd --- /dev/null +++ b/public/favicon-dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/favicon-light.svg b/public/favicon-light.svg new file mode 100644 index 0000000..3f5370a --- /dev/null +++ b/public/favicon-light.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/tests/playwright/theme.spec.ts b/tests/playwright/theme.spec.ts new file mode 100644 index 0000000..f869911 --- /dev/null +++ b/tests/playwright/theme.spec.ts @@ -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); + }); +});