diff --git a/apps/web/package.json b/apps/web/package.json
index 7c3dd53..7660f00 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -32,7 +32,6 @@
"dotenv": "catalog:",
"drizzle-orm": "^0.45.1",
"lucide-react": "^0.525.0",
- "next-themes": "^0.4.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"shadcn": "^3.6.2",
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index 3ae8fd8..4531c1c 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -1,8 +1,12 @@
import { Link } from "@tanstack/react-router";
+import { Moon, Sun } from "lucide-react";
import UserMenu from "./user-menu";
+import { useTheme } from "@/lib/theme";
export default function Header() {
+ const { theme, toggleTheme } = useTheme();
+
const links = [
{ to: "/", label: "Home" },
{ to: "/dashboard", label: "Dashboard" },
@@ -22,6 +26,20 @@ export default function Header() {
})}
+
diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx
index d3b6615..4ed19de 100644
--- a/apps/web/src/components/ui/sonner.tsx
+++ b/apps/web/src/components/ui/sonner.tsx
@@ -1,9 +1,12 @@
import { Toaster as SonnerToaster } from "sonner";
+import { useTheme } from "@/lib/theme";
+
type ToasterProps = React.ComponentProps;
function Toaster(props: ToasterProps) {
- return ;
+ const { theme } = useTheme();
+ return ;
}
export { Toaster };
diff --git a/apps/web/src/lib/flutter-bridge.ts b/apps/web/src/lib/flutter-bridge.ts
index a8e7711..2c714ae 100644
--- a/apps/web/src/lib/flutter-bridge.ts
+++ b/apps/web/src/lib/flutter-bridge.ts
@@ -37,6 +37,7 @@ export type FlutterTimelineState = {
items: Record;
groupOrder: Array;
selectedItemId: string | null;
+ darkMode: boolean;
};
// ---------------------------------------------------------------------------
diff --git a/apps/web/src/lib/theme.tsx b/apps/web/src/lib/theme.tsx
new file mode 100644
index 0000000..d7f6bc8
--- /dev/null
+++ b/apps/web/src/lib/theme.tsx
@@ -0,0 +1,73 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+
+type Theme = "light" | "dark";
+
+type ThemeContextValue = {
+ theme: Theme;
+ toggleTheme: () => void;
+};
+
+const ThemeContext = createContext(null);
+
+const STORAGE_KEY = "zendegi-theme";
+
+function getSystemPreference(): Theme {
+ if (typeof window === "undefined") return "dark";
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+}
+
+function getInitialTheme(): Theme {
+ if (typeof window === "undefined") return "dark";
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored === "light" || stored === "dark") return stored;
+ return getSystemPreference();
+}
+
+function applyTheme(theme: Theme) {
+ if (typeof document === "undefined") return;
+ document.documentElement.classList.toggle("dark", theme === "dark");
+}
+
+export function ThemeProvider({ children }: { children: React.ReactNode }) {
+ const [theme, setTheme] = useState(getInitialTheme);
+
+ useEffect(() => {
+ applyTheme(theme);
+ }, [theme]);
+
+ // Listen for OS-level preference changes when no explicit preference is stored.
+ 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) => {
+ const next = prev === "dark" ? "light" : "dark";
+ localStorage.setItem(STORAGE_KEY, next);
+ return next;
+ });
+ }, []);
+
+ return {children};
+}
+
+export function useTheme(): ThemeContextValue {
+ const ctx = useContext(ThemeContext);
+ if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
+ return ctx;
+}
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 572e857..b3d82a7 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -10,11 +10,16 @@ import Header from "../components/header";
import appCss from "../index.css?url";
import type { QueryClient } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/sonner";
+import { ThemeProvider } from "@/lib/theme";
export interface RouterAppContext {
queryClient: QueryClient;
}
+// Inline script that runs before paint to avoid a flash of wrong theme.
+// Reads localStorage; falls back to system preference; falls back to dark.
+const themeScript = `(function(){try{var t=localStorage.getItem("zendegi-theme");if(!t){t=window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"}if(t==="dark"){document.documentElement.classList.add("dark")}else{document.documentElement.classList.remove("dark")}}catch(e){}})();`;
+
export const Route = createRootRouteWithContext()({
head: () => ({
meta: [
@@ -42,16 +47,19 @@ export const Route = createRootRouteWithContext()({
function RootDocument() {
return (
-
+
+
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/timeline.$timelineId.tsx b/apps/web/src/routes/timeline.$timelineId.tsx
index 6502d9a..1fb38c8 100644
--- a/apps/web/src/routes/timeline.$timelineId.tsx
+++ b/apps/web/src/routes/timeline.$timelineId.tsx
@@ -5,6 +5,7 @@ import type { FlutterEvent, FlutterTimelineState } from "@/lib/flutter-bridge";
import { timelineQueryOptions } from "@/functions/get-timeline";
import { FlutterView } from "@/components/flutter-view";
import { useEntryMovedMutation } from "@/hooks/use-entry-moved-mutation";
+import { useTheme } from "@/lib/theme";
export const Route = createFileRoute("/timeline/$timelineId")({
loader: async ({ context, params }) => {
@@ -21,6 +22,7 @@ function RouteComponent() {
const [selectedItemId, setSelectedItemId] = useState(null);
const [flutterHeight, setFlutterHeight] = useState();
const entryMoved = useEntryMovedMutation(timelineId);
+ const { theme } = useTheme();
const flutterState: FlutterTimelineState = useMemo(
() => ({
@@ -42,8 +44,9 @@ function RouteComponent() {
),
groupOrder: timeline.groupOrder,
selectedItemId,
+ darkMode: theme === "dark",
}),
- [timeline, selectedItemId]
+ [timeline, selectedItemId, theme]
);
const handleEvent = useCallback(
diff --git a/packages/z-timeline/lib/main.dart b/packages/z-timeline/lib/main.dart
index eb89eda..5bea99c 100644
--- a/packages/z-timeline/lib/main.dart
+++ b/packages/z-timeline/lib/main.dart
@@ -20,6 +20,7 @@ class _MainAppState extends State {
List _groups = const [];
List _entries = const [];
TimelineViewportNotifier? _viewport;
+ bool _darkMode = true;
@override
void initState() {
@@ -53,6 +54,7 @@ class _MainAppState extends State {
_state = state;
_groups = groups;
_entries = entries;
+ _darkMode = state.darkMode;
_viewport ??= TimelineViewportNotifier(
start: domain.start,
@@ -207,7 +209,9 @@ class _MainAppState extends State {
return MaterialApp(
debugShowCheckedModeBanner: false,
- theme: ThemeData.dark(useMaterial3: true),
+ theme: _darkMode
+ ? ThemeData.dark(useMaterial3: true)
+ : ThemeData.light(useMaterial3: true),
home: Scaffold(
backgroundColor: Colors.transparent,
body: _state == null || viewport == null
diff --git a/packages/z-timeline/lib/state.dart b/packages/z-timeline/lib/state.dart
index 6cb43d5..d5b6fbb 100644
--- a/packages/z-timeline/lib/state.dart
+++ b/packages/z-timeline/lib/state.dart
@@ -11,6 +11,7 @@ class TimelineState {
final Map items;
final List groupOrder;
final String? selectedItemId;
+ final bool darkMode;
TimelineState({
required this.timeline,
@@ -18,6 +19,7 @@ class TimelineState {
required this.items,
required this.groupOrder,
this.selectedItemId,
+ this.darkMode = true,
});
factory TimelineState.fromJson(Map json) {
@@ -36,6 +38,7 @@ class TimelineState {
),
groupOrder: (json['groupOrder'] as List).cast(),
selectedItemId: json['selectedItemId'] as String?,
+ darkMode: json['darkMode'] as bool? ?? true,
);
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5cb5c60..6de2616 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -123,9 +123,6 @@ importers:
lucide-react:
specifier: ^0.525.0
version: 0.525.0(react@19.2.3)
- next-themes:
- specifier: ^0.4.6
- version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react:
specifier: 19.2.3
version: 19.2.3
@@ -266,6 +263,9 @@ importers:
eslint:
specifier: ^9.17.0
version: 9.39.2(jiti@2.6.1)
+ tsx:
+ specifier: ^4.21.0
+ version: 4.21.0
typescript:
specifier: 'catalog:'
version: 5.9.3
@@ -3561,12 +3561,6 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
- next-themes@0.4.6:
- resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
- peerDependencies:
- react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
- react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
-
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -7774,11 +7768,6 @@ snapshots:
negotiator@1.0.0: {}
- next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
- dependencies:
- react: 19.2.3
- react-dom: 19.2.3(react@19.2.3)
-
node-domexception@1.0.0: {}
node-fetch@3.3.2: