Skip to content

Commit e80453f

Browse files
authored
Merge pull request #135 from luckyalade/fix/light-dark-theme-toggle
Fix: light-dark-theme-toggle
2 parents 2b02835 + 1511e18 commit e80453f

10 files changed

Lines changed: 439 additions & 104 deletions

File tree

357 KB
Loading
357 KB
Loading
357 KB
Loading

frontend/src/app/globals.css

Lines changed: 65 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,58 +2,88 @@
22
@tailwind components;
33
@tailwind utilities;
44

5+
/*
6+
* app/globals.css (App Router)
7+
*
8+
* Theme switching is handled by next-themes which toggles `.dark` on <html>
9+
* (Tailwind darkMode: 'class'). We do NOT use prefers-color-scheme media
10+
* queries here — next-themes does that automatically when defaultTheme="system".
11+
*
12+
* Full design tokens live in styles/globals.css (Pages Router entry).
13+
* This file re-declares only what the App Router pages need directly.
14+
*/
15+
16+
/* ─── Design tokens — light ──────────────────────────────────────── */
517
:root {
6-
--foreground-rgb: 0, 0, 0;
7-
--background-start-rgb: 214, 219, 220;
8-
--background-end-rgb: 255, 255, 255;
18+
--foreground-rgb: 15, 23, 42;
19+
--background-start-rgb: 248, 250, 252;
20+
--background-end-rgb: 255, 255, 255;
21+
22+
/* HSL surface tokens (shadcn-compatible) */
23+
--background: 0 0% 100%;
24+
--foreground: 222.2 84% 4.9%;
25+
--muted: 210 40% 96.1%;
26+
--muted-foreground: 215.4 16.3% 46.9%;
27+
--border: 214.3 31.8% 91.4%;
28+
--ring: 221.2 83.2% 53.3%;
29+
30+
/* Theme transition speed — zeroed by the reduced-motion rule below */
31+
--theme-transition: 200ms;
932
}
1033

11-
@media (prefers-color-scheme: dark) {
12-
:root {
13-
--foreground-rgb: 255, 255, 255;
14-
--background-start-rgb: 0, 0, 0;
15-
--background-end-rgb: 0, 0, 0;
16-
}
34+
/* ─── Design tokens — dark ───────────────────────────────────────── */
35+
.dark {
36+
--foreground-rgb: 248, 250, 252;
37+
--background-start-rgb: 15, 23, 42;
38+
--background-end-rgb: 15, 23, 42;
39+
40+
--background: 222.2 84% 4.9%;
41+
--foreground: 210 40% 98%;
42+
--muted: 217.2 32.6% 17.5%;
43+
--muted-foreground: 215 20.2% 65.1%;
44+
--border: 217.2 32.6% 17.5%;
45+
--ring: 224.3 76.3% 48%;
1746
}
1847

48+
/* ─── Base ───────────────────────────────────────────────────────── */
1949
body {
20-
color: rgb(var(--foreground-rgb));
21-
background: linear-gradient(
22-
to bottom,
23-
transparent,
24-
rgb(var(--background-end-rgb))
25-
)
26-
rgb(var(--background-start-rgb));
50+
color: rgb(var(--foreground-rgb));
51+
background: rgb(var(--background-start-rgb));
52+
transition:
53+
background-color var(--theme-transition) ease,
54+
color var(--theme-transition) ease;
2755
}
2856

29-
/* Custom animations */
30-
@keyframes spin {
31-
from {
32-
transform: rotate(0deg);
33-
}
34-
to {
35-
transform: rotate(360deg);
57+
/* ─── Reduced-motion guard ───────────────────────────────────────── */
58+
@media (prefers-reduced-motion: reduce) {
59+
:root { --theme-transition: 0.01ms; }
60+
*,
61+
*::before,
62+
*::after {
63+
scroll-behavior: auto !important;
64+
animation-duration: 0.01ms !important;
65+
animation-iteration-count: 1 !important;
66+
transition-duration: 0.01ms !important;
3667
}
3768
}
3869

39-
.animate-spin {
40-
animation: spin 1s linear infinite;
41-
}
42-
43-
/* Custom scrollbar */
44-
::-webkit-scrollbar {
45-
width: 6px;
70+
/* ─── Animations ─────────────────────────────────────────────────── */
71+
@keyframes spin {
72+
from { transform: rotate(0deg); }
73+
to { transform: rotate(360deg); }
4674
}
4775

48-
::-webkit-scrollbar-track {
49-
background: #f1f1f1;
76+
.animate-spin {
77+
animation: spin 1s linear infinite;
5078
}
5179

80+
/* ─── Scrollbar (theme-aware) ────────────────────────────────────── */
81+
::-webkit-scrollbar { width: 6px; }
82+
::-webkit-scrollbar-track { background: hsl(var(--muted)); }
5283
::-webkit-scrollbar-thumb {
53-
background: #888;
84+
background: hsl(var(--muted-foreground) / 0.5);
5485
border-radius: 3px;
5586
}
56-
5787
::-webkit-scrollbar-thumb:hover {
58-
background: #555;
88+
background: hsl(var(--muted-foreground) / 0.8);
5989
}

frontend/src/app/layout.tsx

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Metadata } from 'next';
22
import { Inter } from 'next/font/google';
3+
import { ThemeProvider } from 'next-themes';
34
import './globals.css';
45
import { performanceMonitor } from '@/lib/performance-monitor';
56
import { GlobalShell } from '@/components/PWA/GlobalShell';
@@ -26,11 +27,31 @@ export default function RootLayout({
2627
const dir = RTL_LOCALES.has(locale) ? 'rtl' : 'ltr';
2728

2829
return (
29-
<html lang={locale} dir={dir}>
30+
/*
31+
* suppressHydrationWarning is required because next-themes injects a
32+
* `class` attribute on <html> on the client before React hydration
33+
* completes, which would otherwise trigger a mismatch warning.
34+
*/
35+
<html lang={locale} dir={dir} suppressHydrationWarning>
3036
<body className={inter.className}>
31-
<GlobalShell />
32-
<CommandPalette />
33-
{children}
37+
{/*
38+
* ThemeProvider configuration:
39+
* attribute="class" → Tailwind darkMode: 'class' strategy
40+
* defaultTheme="system" → first visit follows OS preference
41+
* enableSystem → listens for prefers-color-scheme changes
42+
* storageKey → persists choice under 'starked-theme'
43+
* disableTransitionOnChange={false} → our CSS handles transitions
44+
*/}
45+
<ThemeProvider
46+
attribute="class"
47+
defaultTheme="system"
48+
enableSystem
49+
storageKey="starked-theme"
50+
disableTransitionOnChange={false}
51+
>
52+
<GlobalShell />
53+
{children}
54+
</ThemeProvider>
3455
</body>
3556
</html>
3657
);

frontend/src/components/PWA/GlobalShell.tsx

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
/**
44
* GlobalShell — client-side wrapper that mounts the persistent PWA chrome
55
* (install banner, offline indicator, update banner) plus a fixed-position
6-
* LanguageSwitcher so it is available on every page WITHOUT stacking on top
7-
* of any in-page `<header>` elements that pages may already render.
6+
* LanguageSwitcher and ThemeToggle so they are available on every page
7+
* WITHOUT stacking on top of any in-page `<header>` elements that pages
8+
* may already render.
89
*
910
* Mounted from both the pages router (`_app.tsx`) and the app router
1011
* (`app/layout.tsx`).
@@ -16,23 +17,29 @@ import { LanguageSwitcher } from '../LanguageSwitcher';
1617

1718
// GlobalPWA uses hooks that depend on `window`, so load it dynamically and
1819
// disable SSR to avoid hydration mismatches.
19-
const GlobalPWA = dynamic(() => import('./GlobalPWA').then((m) => m.GlobalPWA), {
20-
ssr: false,
21-
});
20+
const GlobalPWA = dynamic(
21+
() => import('./GlobalPWA').then((m) => m.GlobalPWA),
22+
{
23+
ssr: false,
24+
}
25+
);
26+
27+
// ThemeToggle reads localStorage and matchMedia — must be client-only.
28+
const ThemeToggle = dynamic(() => import('../ui/ThemeToggle'), { ssr: false });
2229

2330
export const GlobalShell: React.FC = () => {
2431
return (
2532
<>
2633
<GlobalPWA />
27-
{/* Fixed top-right LanguageSwitcher so it doesn't collide with
28-
page-level headers. Reasonable pointer-events / a11y settings
29-
are inherited from the LanguageSwitcher component itself. */}
34+
{/* Fixed top-right strip: ThemeToggle + LanguageSwitcher.
35+
gap-2 keeps them from overlapping; z-40 sits above most content. */}
3036
<div
31-
className="fixed top-2 right-2 z-40"
37+
className="fixed top-2 right-2 z-40 flex items-center gap-2"
3238
style={{ pointerEvents: 'auto' }}
3339
role="region"
34-
aria-label="Language switcher"
40+
aria-label="Page controls"
3541
>
42+
<ThemeToggle />
3643
<LanguageSwitcher variant="compact" />
3744
</div>
3845
</>
Lines changed: 53 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,84 @@
11
'use client';
22

3-
import React from 'react';
4-
import { useTheme } from 'next-themes';
3+
import React, { useEffect, useState } from 'react';
54
import { Sun, Moon, Monitor } from 'lucide-react';
6-
import { useEffect, useState } from 'react';
5+
import { useTheme, type ThemeMode } from '@/hooks/useTheme';
76

8-
type ThemeMode = 'system' | 'light' | 'dark';
7+
// ─── Theme metadata ───────────────────────────────────────────────────────────
98

10-
const themeCycle: ThemeMode[] = ['system', 'light', 'dark'];
11-
12-
const themeIcons: Record<ThemeMode, React.ElementType> = {
13-
system: Monitor,
14-
light: Sun,
15-
dark: Moon,
9+
const THEME_META: Record<
10+
ThemeMode,
11+
{ Icon: React.ElementType; label: string; next: ThemeMode }
12+
> = {
13+
light: { Icon: Sun, label: 'Light', next: 'dark' },
14+
dark: { Icon: Moon, label: 'Dark', next: 'system' },
15+
system: { Icon: Monitor, label: 'System', next: 'light' },
1616
};
1717

18-
const themeLabels: Record<ThemeMode, string> = {
19-
system: 'System',
20-
light: 'Light',
21-
dark: 'Dark',
22-
};
18+
// ─── Component ────────────────────────────────────────────────────────────────
2319

20+
/**
21+
* ThemeToggle
22+
*
23+
* A three-mode toggle button (Light → Dark → System → Light) that:
24+
* - Shows Sun / Moon / Monitor icons matching the active mode.
25+
* - Persists choice to localStorage under `starked-theme`.
26+
* - Respects `prefers-reduced-motion` — disables icon animation when set.
27+
* - Announces the new mode to screen readers via a polite live region.
28+
* - Is hydration-safe: renders a skeleton until the client mounts.
29+
* - Has `aria-label="Toggle theme"` plus a verbose current/next description.
30+
*
31+
* Drop it anywhere — state comes entirely from `useTheme()`.
32+
*
33+
* @example
34+
* <ThemeToggle />
35+
*/
2436
export default function ThemeToggle() {
25-
const { theme, setTheme } = useTheme();
37+
const { theme, cycleTheme, prefersReducedMotion } = useTheme();
2638
const [mounted, setMounted] = useState(false);
2739

28-
// Avoid hydration mismatch by only rendering after mount
40+
// Defer real render until after hydration to prevent SSR mismatch.
2941
useEffect(() => {
3042
setMounted(true);
3143
}, []);
3244

45+
// ── Skeleton (pre-hydration) ─────────────────────────────────────────────
3346
if (!mounted) {
3447
return (
3548
<button
36-
className="w-9 h-9 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 flex items-center justify-center"
49+
className="theme-toggle-btn"
3750
aria-label="Toggle theme"
51+
disabled
3852
>
39-
<span className="w-4 h-4" />
53+
<span className="theme-toggle-icon-placeholder" aria-hidden="true" />
4054
</button>
4155
);
4256
}
4357

44-
const currentMode: ThemeMode = (theme as ThemeMode) || 'system';
45-
const Icon = themeIcons[currentMode];
46-
47-
const handleToggle = () => {
48-
const currentIndex = themeCycle.indexOf(currentMode);
49-
const nextIndex = (currentIndex + 1) % themeCycle.length;
50-
setTheme(themeCycle[nextIndex]);
51-
};
58+
// ── Resolved state ───────────────────────────────────────────────────────
59+
const meta = THEME_META[theme];
60+
const nextLabel = THEME_META[meta.next].label;
61+
const { Icon, label } = meta;
5262

5363
return (
5464
<button
55-
onClick={handleToggle}
56-
className="relative w-9 h-9 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center justify-center transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
57-
aria-label={`Current theme: ${themeLabels[currentMode]}. Click to switch.`}
58-
title={`Theme: ${themeLabels[currentMode]}`}
65+
id="theme-toggle"
66+
onClick={cycleTheme}
67+
className={[
68+
'theme-toggle-btn',
69+
prefersReducedMotion ? 'no-transition' : '',
70+
]
71+
.filter(Boolean)
72+
.join(' ')}
73+
aria-label={`Toggle theme. Current: ${label}. Click to switch to ${nextLabel}.`}
74+
title={`Theme: ${label}`}
5975
>
60-
<Icon className="h-4 w-4 text-gray-700 dark:text-gray-300" />
61-
<span className="sr-only">Theme: {themeLabels[currentMode]}</span>
76+
{/* Icon rotates slightly on click via CSS */}
77+
<span className="theme-toggle-icon" aria-hidden="true">
78+
<Icon className="h-4 w-4" />
79+
</span>
80+
{/* Visible to screen readers only */}
81+
<span className="sr-only">Theme: {label}</span>
6282
</button>
6383
);
6484
}

0 commit comments

Comments
 (0)