diff --git a/app/layout.tsx b/app/layout.tsx
index a3d202e..833f762 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,64 +1,16 @@
"use client";
-import { useState, useEffect, type ReactNode } from "react";
+import { useEffect, useRef, useState, type ReactNode } from "react";
import Link from "next/link";
import { ToastProvider } from "@/components/ToastProvider";
import { ConfirmDialogProvider } from "@/components/ConfirmDialogProvider";
import { SettingsProvider } from "@/components/SettingsProvider";
+import { ThemeProvider, useThemeMode } from "@/components/ThemeProvider";
import "./globals.css";
export default function RootLayout({ children }: { children: ReactNode }) {
- const [isDark, setIsDark] = useState(false);
- const [mounted, setMounted] = useState(false);
-
- useEffect(() => {
- setMounted(true);
- const savedTheme = localStorage.getItem("theme");
- const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
- const shouldBeDark = savedTheme ? savedTheme === "dark" : prefersDark;
-
- setIsDark(shouldBeDark);
- if (shouldBeDark) {
- document.documentElement.classList.add("dark");
- }
- }, []);
-
- const toggleDarkMode = () => {
- const newIsDark = !isDark;
- setIsDark(newIsDark);
- localStorage.setItem("theme", newIsDark ? "dark" : "light");
-
- if (newIsDark) {
- document.documentElement.classList.add("dark");
- } else {
- document.documentElement.classList.remove("dark");
- }
- };
-
- if (!mounted) {
- return (
-
-
- Assertify
-
-
-
-
-
-
-
-
-
- );
- }
-
return (
-
+
Assertify
@@ -73,29 +25,10 @@ export default function RootLayout({ children }: { children: ReactNode }) {
-
-
-
-
-
-
-
- {children}
+
+
+ {children}
+
@@ -103,3 +36,72 @@ export default function RootLayout({ children }: { children: ReactNode }) {
);
}
+
+function MobileDock() {
+ const { themeMode, toggleTheme, mounted } = useThemeMode();
+ const [open, setOpen] = useState(false);
+ const menuRef = useRef(null);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ setOpen(false);
+ }
+ };
+ const handleEsc = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setOpen(false);
+ };
+ document.addEventListener("mousedown", handleClickOutside);
+ document.addEventListener("keydown", handleEsc);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ document.removeEventListener("keydown", handleEsc);
+ };
+ }, []);
+
+ if (!mounted) return null;
+
+ return (
+
+
+
+ {open && (
+
+ setOpen(false)}
+ aria-label="Open settings"
+ title="Settings"
+ >
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index fd14183..b7c0f2f 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,6 +1,7 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useRef } from "react";
+import Link from "next/link";
import { useRouter } from "next/navigation";
import SavedTestsList from "@/components/SavedTestsList";
import { useToast } from "@/components/ToastProvider";
@@ -8,6 +9,7 @@ import { useConfirmDialog } from "@/components/ConfirmDialogProvider";
import { buildAutoContext } from "@/lib/autoContext";
import { useSettings } from "@/components/SettingsProvider";
import { filterTestCasesBySettings } from "@/lib/testCaseUtils";
+import { useThemeMode } from "@/components/ThemeProvider";
export default function Home() {
const [input, setInput] = useState("");
@@ -18,10 +20,23 @@ export default function Home() {
const [needsManualContext, setNeedsManualContext] = useState(false);
const [requirements, setRequirements] = useState("");
const [showRequirementsInput, setShowRequirementsInput] = useState(false);
+ const [showDesktopMenu, setShowDesktopMenu] = useState(false);
+ const controlsRef = useRef(null);
const router = useRouter();
const { addToast } = useToast();
const { confirm } = useConfirmDialog();
const { settings } = useSettings();
+ const { themeMode, themeLabel, toggleTheme, mounted } = useThemeMode();
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (controlsRef.current && !controlsRef.current.contains(event.target as Node)) {
+ setShowDesktopMenu(false);
+ }
+ };
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
useEffect(() => {
const savedKey = localStorage.getItem("openai_api_key");
@@ -197,18 +212,61 @@ export default function Home() {
};
return (
-
-
-
+
+
+
{/* Header */}
-
-
+
+
Assertify
AI-powered test case generation with boilerplate code
+ {mounted && (
+
+
+ {showDesktopMenu && (
+
+
+
+
+
+
+ )}
+
+ )}
{/* API Key Section */}
@@ -366,20 +424,20 @@ export default function Home() {
{/* Features */}
-
-
+
+
-
+
-
+
diff --git a/components/ThemeProvider.tsx b/components/ThemeProvider.tsx
new file mode 100644
index 0000000..199e188
--- /dev/null
+++ b/components/ThemeProvider.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
+
+type ThemeMode = "light" | "dark" | "system";
+
+interface ThemeContextValue {
+ themeMode: ThemeMode;
+ isDark: boolean;
+ themeLabel: string;
+ mounted: boolean;
+ toggleTheme: () => void;
+}
+
+const ThemeContext = createContext
(undefined);
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const [isDark, setIsDark] = useState(false);
+ const [themeMode, setThemeMode] = useState("system");
+ const [mounted, setMounted] = useState(false);
+
+ const applyTheme = useCallback((mode: ThemeMode, prefersDark: boolean) => {
+ const shouldUseDark = mode === "dark" || (mode === "system" && prefersDark);
+ setIsDark(shouldUseDark);
+ document.documentElement.classList.toggle("dark", shouldUseDark);
+ }, []);
+
+ useEffect(() => {
+ setMounted(true);
+ const savedTheme = (localStorage.getItem("themeMode") ||
+ localStorage.getItem("theme")) as ThemeMode | null;
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
+ const resolvedMode: ThemeMode =
+ savedTheme === "light" || savedTheme === "dark" || savedTheme === "system"
+ ? savedTheme
+ : "system";
+
+ setThemeMode(resolvedMode);
+ applyTheme(resolvedMode, prefersDark);
+ }, [applyTheme]);
+
+ useEffect(() => {
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+ const handleChange = (event: MediaQueryListEvent) => {
+ if (themeMode === "system") {
+ applyTheme("system", event.matches);
+ }
+ };
+
+ mediaQuery.addEventListener("change", handleChange);
+ return () => mediaQuery.removeEventListener("change", handleChange);
+ }, [applyTheme, themeMode]);
+
+ const toggleTheme = useCallback(() => {
+ const nextMode: ThemeMode =
+ themeMode === "light" ? "dark" : themeMode === "dark" ? "system" : "light";
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
+ setThemeMode(nextMode);
+ localStorage.setItem("themeMode", nextMode);
+ localStorage.setItem("theme", nextMode);
+ applyTheme(nextMode, prefersDark);
+ }, [applyTheme, themeMode]);
+
+ const themeLabel = useMemo(
+ () => (themeMode === "system" ? "System" : themeMode === "dark" ? "Dark" : "Light"),
+ [themeMode]
+ );
+
+ const value = useMemo(
+ () => ({ themeMode, isDark, themeLabel, mounted, toggleTheme }),
+ [isDark, mounted, themeLabel, themeMode, toggleTheme]
+ );
+
+ return {children};
+}
+
+export function useThemeMode() {
+ const ctx = useContext(ThemeContext);
+ if (!ctx) {
+ throw new Error("useThemeMode must be used within a ThemeProvider");
+ }
+ return ctx;
+}