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);
+ });
+});