feat: add dark mode with famille-website-inspired warm gray palette

Toggle via Moon/Sun button in sidebar. Persists to localStorage with
prefers-color-scheme fallback. Fixes hardcoded colors (error banners,
badges, chart tooltips, Settings text) to use CSS variables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-13 12:28:11 +00:00
parent c73f466429
commit f7fb6910b6
15 changed files with 114 additions and 29 deletions

View file

@ -43,6 +43,14 @@ export default function CategoryPieChart({ data }: CategoryPieChartProps) {
formatter={(value) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value))
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
</PieChart>
</ResponsiveContainer>

View file

@ -151,8 +151,8 @@ export default function DuplicateCheckPanel({
<span
className={`inline-block px-2 py-0.5 text-xs rounded-full ${
isBatch
? "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
? "bg-[var(--accent)]/15 text-[var(--accent)]"
: "bg-[var(--primary)]/15 text-[var(--primary)]"
}`}
>
{isBatch

View file

@ -78,7 +78,7 @@ export default function ImportConfirmation({
{/* Rows to import */}
<div className="p-4 flex items-center gap-3">
<CheckCircle size={18} className="text-emerald-500" />
<CheckCircle size={18} className="text-[var(--positive)]" />
<div>
<p className="text-sm font-medium">
{t("import.confirm.rowsToImport")}

View file

@ -38,7 +38,7 @@ export default function SourceCard({
{isConfigured && (
<CheckCircle
size={16}
className="text-emerald-500"
className="text-[var(--positive)]"
/>
)}
</div>

View file

@ -10,8 +10,11 @@ import {
BarChart3,
Settings,
Languages,
Moon,
Sun,
} from "lucide-react";
import { NAV_ITEMS, APP_NAME } from "../../shared/constants";
import { useTheme } from "../../hooks/useTheme";
const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
LayoutDashboard,
@ -26,6 +29,7 @@ const iconMap: Record<string, React.ComponentType<{ size?: number }>> = {
export default function Sidebar() {
const { t, i18n } = useTranslation();
const { theme, toggleTheme } = useTheme();
const toggleLanguage = () => {
const next = i18n.language === "fr" ? "en" : "fr";
@ -60,7 +64,14 @@ export default function Sidebar() {
})}
</nav>
<div className="p-3 border-t border-white/10">
<div className="p-3 border-t border-white/10 space-y-1">
<button
onClick={toggleTheme}
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm hover:bg-[var(--sidebar-hover)] transition-colors"
>
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
<span>{theme === "dark" ? t("common.lightMode") : t("common.darkMode")}</span>
</button>
<button
onClick={toggleLanguage}
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm hover:bg-[var(--sidebar-hover)] transition-colors"

View file

@ -51,7 +51,10 @@ export default function CategoryBarChart({ data }: CategoryBarChartProps) {
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
<Bar dataKey="total" name={t("dashboard.expenses")} radius={[0, 4, 4, 0]}>
{data.map((item, index) => (

View file

@ -59,7 +59,10 @@ export default function CategoryOverTimeChart({ data }: CategoryOverTimeChartPro
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
<Legend />
{data.categories.map((name) => (

View file

@ -68,7 +68,10 @@ export default function MonthlyTrendsChart({ data }: MonthlyTrendsChartProps) {
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
<Area
type="monotone"

42
src/hooks/useTheme.ts Normal file
View file

@ -0,0 +1,42 @@
import { useState, useEffect, useCallback } from "react";
type Theme = "light" | "dark";
const STORAGE_KEY = "theme";
function getInitialTheme(): Theme {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme: Theme) {
document.documentElement.classList.toggle("dark", theme === "dark");
}
export function useTheme() {
const [theme, setTheme] = useState<Theme>(getInitialTheme);
useEffect(() => {
applyTheme(theme);
localStorage.setItem(STORAGE_KEY, theme);
}, [theme]);
// Listen for OS preference changes (only when no explicit user choice)
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
if (!localStorage.getItem(STORAGE_KEY)) {
setTheme(e.matches ? "dark" : "light");
}
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const toggleTheme = useCallback(() => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
}, []);
return { theme, toggleTheme } as const;
}

View file

@ -362,6 +362,8 @@
"noResults": "No results",
"confirm": "Confirm",
"language": "Language",
"total": "Total"
"total": "Total",
"darkMode": "Dark mode",
"lightMode": "Light mode"
}
}

View file

@ -362,6 +362,8 @@
"noResults": "Aucun résultat",
"confirm": "Confirmer",
"language": "Langue",
"total": "Total"
"total": "Total",
"darkMode": "Mode sombre",
"lightMode": "Mode clair"
}
}

View file

@ -39,7 +39,7 @@ export default function BudgetPage() {
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-100 text-red-800 text-sm border border-red-200">
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
{error}
</div>
)}

View file

@ -40,7 +40,7 @@ export default function ReportsPage() {
</div>
{state.error && (
<div className="bg-red-100 text-red-700 rounded-xl p-4 mb-6">
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{state.error}
</div>
)}

View file

@ -45,7 +45,7 @@ export default function SettingsPage() {
</div>
<div>
<h2 className="text-lg font-semibold">{APP_NAME}</h2>
<p className="text-sm text-[var(--muted)]">
<p className="text-sm text-[var(--muted-foreground)]">
{t("settings.version", { version })}
</p>
</div>
@ -72,7 +72,7 @@ export default function SettingsPage() {
{/* checking */}
{state.status === "checking" && (
<div className="flex items-center gap-2 text-[var(--muted)]">
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
<Loader2 size={16} className="animate-spin" />
{t("settings.updates.checking")}
</div>
@ -87,7 +87,7 @@ export default function SettingsPage() {
</div>
<button
onClick={checkForUpdate}
className="text-sm text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<RefreshCw size={14} />
</button>
@ -113,7 +113,7 @@ export default function SettingsPage() {
{/* downloading */}
{state.status === "downloading" && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-[var(--muted)]">
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
<Loader2 size={16} className="animate-spin" />
{t("settings.updates.downloading")}
{progressPercent !== null && <span>{progressPercent}%</span>}
@ -145,7 +145,7 @@ export default function SettingsPage() {
{/* installing */}
{state.status === "installing" && (
<div className="flex items-center gap-2 text-[var(--muted)]">
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
<Loader2 size={16} className="animate-spin" />
{t("settings.updates.installing")}
</div>
@ -158,7 +158,7 @@ export default function SettingsPage() {
<AlertCircle size={16} />
{t("settings.updates.error")}
</div>
<p className="text-sm text-[var(--muted)]">{state.error}</p>
<p className="text-sm text-[var(--muted-foreground)]">{state.error}</p>
<button
onClick={checkForUpdate}
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors"
@ -171,7 +171,7 @@ export default function SettingsPage() {
</div>
{/* Data safety notice */}
<div className="flex items-start gap-2 text-sm text-[var(--muted)]">
<div className="flex items-start gap-2 text-sm text-[var(--muted-foreground)]">
<ShieldCheck size={16} className="mt-0.5 shrink-0" />
<p>{t("settings.dataSafeNotice")}</p>
</div>

View file

@ -59,28 +59,35 @@
--negative: #ef4444;
}
/* Dark mode */
/* Dark mode — inspired by famille-website warm gray palette */
.dark {
--background: #1a1a2e;
--foreground: var(--color-creme-100);
--card: #16213e;
--card-foreground: var(--color-creme-100);
--background: #111827;
--foreground: #f3f4f6;
--card: #1f2937;
--card-foreground: #f3f4f6;
--primary: var(--color-bleu-400);
--primary-foreground: #1a1a2e;
--primary-foreground: #111827;
--accent: var(--color-terracotta-400);
--accent-foreground: #1a1a2e;
--muted: #16213e;
--accent-foreground: #111827;
--muted: #374151;
--muted-foreground: #9ca3af;
--border: #2a2a4a;
--sidebar-bg: #0f0f23;
--sidebar-fg: var(--color-creme-200);
--sidebar-hover: #16213e;
--sidebar-active: #1a2744;
--border: #374151;
--sidebar-bg: #0f172a;
--sidebar-fg: #f3f4f6;
--sidebar-hover: #1e293b;
--sidebar-active: #334155;
--positive: #4ade80;
--negative: #f87171;
}
/* Base styles */
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
}
body {
background-color: var(--background);
color: var(--foreground);
@ -89,6 +96,10 @@ body {
min-height: 100vh;
}
*, *::before, *::after {
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;