diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 3e1854c..171c7ea 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next";
import { GeistSans } from "geist/font/sans";
+import { ThemeProvider } from "@/components/theme-provider";
import { AppShell } from "@/components/app-shell";
import { Toaster } from "@/components/ui/sonner";
import "./globals.css";
@@ -21,15 +22,19 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
// `suppressHydrationWarning` here is for *attribute* differences only.
- // Browser extensions (password managers, accessibility tools, the
- // Google `__gcrremoteframetoken` injector, etc.) commonly add data-/
- // dunder attributes to the element after the document loads,
- // which React 19 otherwise flags as a hydration mismatch. Children
- // are still hydration-checked normally.
+ // Two sources legitimately mutate /
attributes after the
+ // document loads:
+ // - next-themes adds the `class="light|dark"` (and the colour-scheme
+ // style) before React hydrates,
+ // - browser extensions inject dunder attributes like
+ // `__gcrremoteframetoken`, password-manager flags, etc.
+ // Children are still hydration-checked normally so real bugs surface.
- {children}
-
+
+ {children}
+
+
);
diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx
index 6cb4d6f..871aeaa 100644
--- a/apps/web/src/app/settings/page.tsx
+++ b/apps/web/src/app/settings/page.tsx
@@ -1,6 +1,7 @@
import { getSeededOperator } from "@/lib/operator";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
+import { ThemeToggle } from "@/components/theme-toggle";
export default async function SettingsPage() {
const op = await getSeededOperator();
@@ -23,6 +24,16 @@ export default async function SettingsPage() {
+
+
+ Appearance
+
+
+ Theme
+
+
+
+
cm WhatsApp Bot ยท self-hosted
diff --git a/apps/web/src/components/theme-provider.tsx b/apps/web/src/components/theme-provider.tsx
new file mode 100644
index 0000000..fcb9b9d
--- /dev/null
+++ b/apps/web/src/components/theme-provider.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { ThemeProvider as NextThemesProvider } from "next-themes";
+import type { ComponentProps } from "react";
+
+type ThemeProviderProps = ComponentProps;
+
+export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/theme-toggle.tsx b/apps/web/src/components/theme-toggle.tsx
new file mode 100644
index 0000000..b7b0927
--- /dev/null
+++ b/apps/web/src/components/theme-toggle.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import { useTheme } from "next-themes";
+import { Moon, Sun, Monitor } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+export function ThemeToggle() {
+ const { setTheme, theme } = useTheme();
+ return (
+
+
+
+
+
+ setTheme("light")}>
+ Light
+
+ setTheme("dark")}>
+ Dark
+
+ setTheme("system")}>
+ System
+
+
+
+ );
+}
diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx
index 994436c..9280ee5 100644
--- a/apps/web/src/components/ui/sonner.tsx
+++ b/apps/web/src/components/ui/sonner.tsx
@@ -1,12 +1,15 @@
"use client"
+import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
return (