Sfoglia il codice sorgente

Add dark theme and tests by Claude Code & Formatting code

jerryliao 2 giorni fa
parent
commit
4e6d300a55

+ 10 - 0
assets/global.css

@@ -1,5 +1,7 @@
 @import "tailwindcss";
 
+@custom-variant dark (&:where(.dark, .dark *));
+
 /* Loading spin animation */
 @keyframes pd-loading-spin {
   0%,
@@ -36,3 +38,11 @@
 .custom-scrollbar::-webkit-scrollbar-thumb:hover {
   background-color: #a8bbbf;
 }
+
+.dark .custom-scrollbar::-webkit-scrollbar-thumb {
+  background-color: #4b5563;
+}
+
+.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
+  background-color: #6b7280;
+}

+ 19 - 16
components/form/Button.tsx

@@ -6,32 +6,35 @@ interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
   children: JSX.Element | string;
 }
 
-export default function Button({ 
-  variant = "default", 
-  size = "md", 
-  children, 
-  className = "", 
-  ...props 
+export default function Button({
+  variant = "default",
+  size = "md",
+  children,
+  className = "",
+  ...props
 }: ButtonProps) {
-  const baseClasses = "box-border cursor-pointer font-normal text-sm border border-gray-300 rounded";
-  
+  const baseClasses =
+    "box-border cursor-pointer font-normal text-sm border border-gray-300 dark:border-gray-600 rounded";
+
   const variantClasses = {
-    default: "bg-white text-gray-800",
+    default: "bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100",
     primary: "bg-blue-600 text-white border-blue-600",
-    danger: "bg-red-600 text-white border-red-600"
+    danger: "bg-red-600 text-white border-red-600",
   };
-  
+
   const sizeClasses = {
     sm: "px-2 py-1 text-xs",
     md: "px-3 py-1.5",
-    lg: "px-4 py-2 text-base"
+    lg: "px-4 py-2 text-base",
   };
-  
-  const combinedClasses = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
-  
+
+  const combinedClasses = `${baseClasses} ${variantClasses[variant]} ${
+    sizeClasses[size]
+  } ${className}`;
+
   return (
     <button className={combinedClasses} {...props}>
       {children}
     </button>
   );
-}
+}

+ 8 - 8
components/form/Checkbox.tsx

@@ -4,21 +4,21 @@ interface CheckboxProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
   label?: string;
 }
 
-export default function Checkbox({ 
-  label, 
-  className = "", 
-  ...props 
+export default function Checkbox({
+  label,
+  className = "",
+  ...props
 }: CheckboxProps) {
   const checkboxClasses = `w-4 h-4 ${className}`;
-  
+
   if (label) {
     return (
       <label className="flex items-center">
         <input type="checkbox" className={checkboxClasses} {...props} />
-        {label && <span className="ml-2">{label}</span>}
+        {label && <span className="ml-2 dark:text-gray-100">{label}</span>}
       </label>
     );
   }
-  
+
   return <input type="checkbox" className={checkboxClasses} {...props} />;
-}
+}

+ 11 - 9
components/form/Input.tsx

@@ -5,14 +5,16 @@ interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
   label?: string;
 }
 
-export default function Input({ 
-  error = false, 
-  label, 
-  className = "", 
-  ...props 
+export default function Input({
+  error = false,
+  label,
+  className = "",
+  ...props
 }: InputProps) {
-  const inputClasses = `w-full block box-border rounded border ${error ? "border-red-600" : "border-gray-300"} text-sm outline-none h-[38px] leading-[30px] py-1 px-1.5 ${className}`;
-  
+  const inputClasses = `w-full block box-border rounded border ${
+    error ? "border-red-600" : "border-gray-300 dark:border-gray-600"
+  } text-sm outline-none h-[38px] leading-[30px] py-1 px-1.5 dark:bg-gray-800 dark:text-gray-100 ${className}`;
+
   if (label) {
     return (
       <div className="mb-2">
@@ -21,6 +23,6 @@ export default function Input({
       </div>
     );
   }
-  
+
   return <input className={inputClasses} {...props} />;
-}
+}

+ 13 - 10
components/form/Textarea.tsx

@@ -1,18 +1,21 @@
 import { JSX } from "preact";
 
-interface TextareaProps extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> {
+interface TextareaProps
+  extends JSX.TextareaHTMLAttributes<HTMLTextAreaElement> {
   error?: boolean;
   label?: string;
 }
 
-export default function Textarea({ 
-  error = false, 
-  label, 
-  className = "", 
-  ...props 
+export default function Textarea({
+  error = false,
+  label,
+  className = "",
+  ...props
 }: TextareaProps) {
-  const textareaClasses = `w-full block box-border rounded border ${error ? "border-red-600" : "border-gray-300"} text-sm outline-none h-full p-1.5 resize-none ${className}`;
-  
+  const textareaClasses = `w-full block box-border rounded border ${
+    error ? "border-red-600" : "border-gray-300 dark:border-gray-600"
+  } text-sm outline-none h-full p-1.5 resize-none dark:bg-gray-800 dark:text-gray-100 ${className}`;
+
   if (label) {
     return (
       <div className="mb-2">
@@ -21,6 +24,6 @@ export default function Textarea({
       </div>
     );
   }
-  
+
   return <textarea className={textareaClasses} {...props} />;
-}
+}

+ 7 - 4
components/layout/PageContainer.tsx

@@ -6,13 +6,16 @@ interface PageContainerProps {
   className?: string;
 }
 
-export default function PageContainer({ children, centered = false, className = "" }: PageContainerProps) {
-  const baseClasses = "w-screen min-w-[375px] h-screen p-3 flex flex-col box-border overflow-hidden";
+export default function PageContainer(
+  { children, centered = false, className = "" }: PageContainerProps,
+) {
+  const baseClasses =
+    "w-screen min-w-[375px] h-screen p-3 flex flex-col box-border overflow-hidden";
   const centeredClasses = centered ? "items-center justify-center" : "";
-  
+
   return (
     <div className={`${baseClasses} ${centeredClasses} ${className}`}>
       {children}
     </div>
   );
-}
+}

+ 25 - 3
islands/Editor.tsx

@@ -4,7 +4,6 @@ import { asset } from "fresh/runtime";
 import { debounce, DebouncedFunction } from "@std/async";
 import Textarea from "../components/form/Textarea.tsx";
 
-
 export enum EditorMode {
   Edit = 1,
   Read = 2,
@@ -99,6 +98,14 @@ export default function Editor(props: EditorProps) {
     removeEventListener("beforeunload", onUnload);
   };
 
+  // Sync shadow root dark theme class
+  const syncShadowTheme = () => {
+    if (shadowRoot) {
+      const isDark = document.documentElement.classList.contains("dark");
+      shadowRoot.classList.toggle("dark-theme", isDark);
+    }
+  };
+
   // Render converted content to shadow root
   const renderContentToShadow = () => {
     if (readViewRef && readViewRef.current) {
@@ -115,6 +122,7 @@ export default function Editor(props: EditorProps) {
         renderStyleToShadow();
       }
       shadowRoot.innerHTML = convertedContent;
+      syncShadowTheme();
     }
   };
 
@@ -137,12 +145,19 @@ export default function Editor(props: EditorProps) {
     }
   };
 
+  // Theme change listener
+  const themeChangeListener = () => {
+    syncShadowTheme();
+  };
+
   // Init event listeners
   useEffect(() => {
     addEventListener("ModeChange", modeChangeListener);
+    document.addEventListener("ThemeChange", themeChangeListener);
 
     return () => {
       removeEventListener("ModeChange", modeChangeListener);
+      document.removeEventListener("ThemeChange", themeChangeListener);
     };
   }, []);
 
@@ -212,7 +227,12 @@ export default function Editor(props: EditorProps) {
     <div className="w-full flex justify-between box-border overflow-hidden flex-shrink-0 flex-grow-1">
       {props.allowMode !== EditorMode.Read
         ? (
-          <div className={`h-[calc(100vh-0.75rem*3-30px)] border border-gray-300 rounded box-border text-gray-800 overflow-auto flex-shrink-0 flex-basis-0 flex-grow-1 custom-scrollbar ${mode === EditorMode.Both ? "mr-1.5" : ""} ${mode === EditorMode.Read ? "hidden" : ""}`} ref={editViewRef}>
+          <div
+            className={`h-[calc(100vh-0.75rem*3-30px)] border border-gray-300 dark:border-gray-700 rounded box-border text-gray-800 dark:text-gray-100 overflow-auto flex-shrink-0 flex-basis-0 flex-grow-1 custom-scrollbar ${
+              mode === EditorMode.Both ? "mr-1.5" : ""
+            } ${mode === EditorMode.Read ? "hidden" : ""}`}
+            ref={editViewRef}
+          >
             <Textarea
               className="border-none rounded-none custom-scrollbar"
               spellcheck={false}
@@ -236,7 +256,9 @@ export default function Editor(props: EditorProps) {
       {props.allowMode !== EditorMode.Edit
         ? (
           <div
-            className={`h-[calc(100vh-0.75rem*3-30px)] border border-gray-300 rounded box-border text-gray-800 overflow-auto flex-shrink-0 flex-basis-0 flex-grow-1 p-1.5 custom-scrollbar ${mode === EditorMode.Both ? "ml-1.5" : ""} ${mode === EditorMode.Edit ? "hidden" : ""}`}
+            className={`h-[calc(100vh-0.75rem*3-30px)] border border-gray-300 dark:border-gray-700 rounded box-border text-gray-800 dark:text-gray-100 overflow-auto flex-shrink-0 flex-basis-0 flex-grow-1 p-1.5 custom-scrollbar ${
+              mode === EditorMode.Both ? "ml-1.5" : ""
+            } ${mode === EditorMode.Edit ? "hidden" : ""}`}
             ref={readViewRef}
             onScroll={() => {
               onScroll(EditorMode.Read);

+ 5 - 1
islands/HomeBar.tsx

@@ -1,5 +1,6 @@
 import Input from "../components/form/Input.tsx";
 import Button from "../components/form/Button.tsx";
+import ThemeToggle from "./ThemeToggle.tsx";
 
 interface HomeBarProps {
   name: string;
@@ -107,7 +108,7 @@ export default function HomeBar(props: HomeBarProps) {
       >
         New Post
       </Button>
-      <div>
+      <div className="flex items-center">
         <span>{props.name}</span>
         <Button
           type="button"
@@ -123,6 +124,9 @@ export default function HomeBar(props: HomeBarProps) {
         >
           Logout
         </Button>
+        <span className="ml-3">
+          <ThemeToggle />
+        </span>
       </div>
     </div>
   );

+ 6 - 2
islands/Loading.tsx

@@ -24,9 +24,13 @@ export default function Loading() {
   }, []);
 
   return (
-    <div className={`fixed inset-0 bg-black/60 z-[9] flex items-center justify-center ${visible ? "" : "hidden"}`}>
+    <div
+      className={`fixed inset-0 bg-black/60 z-[9] flex items-center justify-center ${
+        visible ? "" : "hidden"
+      }`}
+    >
       <div className="inline-block transform translate-z-[1px]">
-        <div className="inline-block w-16 h-16 m-2 rounded-full bg-white animate-[pd-loading-spin_5s_cubic-bezier(0,0.2,0.8,1)_infinite]" />
+        <div className="inline-block w-16 h-16 m-2 rounded-full bg-white dark:bg-gray-200 animate-[pd-loading-spin_5s_cubic-bezier(0,0.2,0.8,1)_infinite]" />
       </div>
     </div>
   );

+ 11 - 3
islands/LoginFrame.tsx

@@ -1,7 +1,7 @@
 import { useEffect, useState } from "preact/hooks";
 import Input from "../components/form/Input.tsx";
 import Button from "../components/form/Button.tsx";
-
+import ThemeToggle from "./ThemeToggle.tsx";
 
 interface LoginFrameProps {
   mode: "login" | "register";
@@ -97,7 +97,10 @@ export default function LoginFrame(props: LoginFrameProps) {
   };
 
   return (
-    <div className="w-[375px] mt-4 box-border p-4 text-gray-800 flex flex-col">
+    <div className="w-[375px] mt-4 box-border p-4 text-gray-800 dark:text-gray-100 flex flex-col relative">
+      <div className="absolute top-0 right-0">
+        <ThemeToggle />
+      </div>
       <Input
         label="Email"
         error={emailError}
@@ -145,7 +148,12 @@ export default function LoginFrame(props: LoginFrameProps) {
           />
         )
         : null}
-      <Button variant="primary" className="h-[38px] mt-2 mb-2" type="button" onClick={onSubmit}>
+      <Button
+        variant="primary"
+        className="h-[38px] mt-2 mb-2"
+        type="button"
+        onClick={onSubmit}
+      >
         {props.mode === "register" ? "Register" : "Sign in"}
       </Button>
       <Button

+ 14 - 4
islands/Modal.tsx

@@ -58,19 +58,29 @@ export default function Modal() {
 
   return (
     <>
-      <div className={`fixed inset-0 bg-black/60 z-[8] flex items-center justify-center ${!visible ? "hidden" : ""}`}>
-        <div className="bg-white border border-gray-200 rounded-lg w-[500px] max-w-[90%] max-h-[60%] relative text-base cursor-pointer">
+      <div
+        className={`fixed inset-0 bg-black/60 z-[8] flex items-center justify-center ${
+          !visible ? "hidden" : ""
+        }`}
+      >
+        <div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg w-[500px] max-w-[90%] max-h-[60%] relative text-base cursor-pointer">
           <i
             className="bi bi-x absolute right-4 top-3 text-2xl"
             onClick={() => {
               hideModal();
             }}
           />
-          {title ? <div className="p-4 border-b border-gray-200 font-medium">{title}</div> : null}
+          {title
+            ? (
+              <div className="p-4 border-b border-gray-200 dark:border-gray-700 font-medium">
+                {title}
+              </div>
+            )
+            : null}
           <div className="p-4">{content}</div>
           {actions.length > 0
             ? (
-              <div className="flex justify-end border-t border-gray-200 p-4">
+              <div className="flex justify-end border-t border-gray-200 dark:border-gray-700 p-4">
                 {actions.map((action, index) => (
                   <Button
                     type="button"

+ 7 - 2
islands/PostList.tsx

@@ -40,7 +40,10 @@ export default function PostList(props: PostListProps) {
   return (
     <div className="w-full grid grid-cols-4 gap-4 mt-4 pb-4 overflow-auto">
       {props.posts.map((post) => (
-        <div className="w-full min-w-[180px] border border-gray-200 rounded box-border relative p-4 text-base flex flex-col" key={post.id}>
+        <div
+          className="w-full min-w-[180px] border border-gray-200 dark:border-gray-700 dark:bg-gray-800 rounded box-border relative p-4 text-base flex flex-col"
+          key={post.id}
+        >
           <span className="font-medium mb-2">{post.title || "Untitled"}</span>
           <div className="absolute right-4 top-3 text-2xl z-[1]">
             <i
@@ -50,7 +53,9 @@ export default function PostList(props: PostListProps) {
               }}
             />
           </div>
-          <span className="overflow-hidden text-ellipsis whitespace-nowrap mb-2">{post.content || "No content"}</span>
+          <span className="overflow-hidden text-ellipsis whitespace-nowrap mb-2">
+            {post.content || "No content"}
+          </span>
           <Button
             type="button"
             onClick={() => {

+ 27 - 0
islands/ThemeToggle.tsx

@@ -0,0 +1,27 @@
+import { useEffect, useState } from "preact/hooks";
+
+export default function ThemeToggle() {
+  const [dark, setDark] = useState(false);
+
+  useEffect(() => {
+    setDark(document.documentElement.classList.contains("dark"));
+  }, []);
+
+  const toggle = () => {
+    const isDark = !dark;
+    setDark(isDark);
+    document.documentElement.classList.toggle("dark", isDark);
+    localStorage.setItem("theme", isDark ? "dark" : "light");
+    document.dispatchEvent(new CustomEvent("ThemeChange", { detail: isDark }));
+  };
+
+  return (
+    <i
+      className={`bi ${
+        dark ? "bi-sun" : "bi-moon-stars"
+      } text-base cursor-pointer hover:text-blue-600 dark:hover:text-blue-400`}
+      onClick={toggle}
+      title={dark ? "Switch to light mode" : "Switch to dark mode"}
+    />
+  );
+}

+ 14 - 9
islands/TopBar.tsx

@@ -3,6 +3,7 @@ import { EditorMode } from "./Editor.tsx";
 import Button from "../components/form/Button.tsx";
 import Input from "../components/form/Input.tsx";
 import Checkbox from "../components/form/Checkbox.tsx";
+import ThemeToggle from "./ThemeToggle.tsx";
 
 interface TopBarProps {
   allowMode: EditorMode;
@@ -150,7 +151,7 @@ export default function TopBar(props: TopBarProps) {
   return (
     <div className="w-full flex mb-3 justify-between box-border flex-shrink-0">
       <div
-        className={`border border-gray-300 rounded box-border text-sm ${
+        className={`border border-gray-300 dark:border-gray-700 rounded box-border text-sm ${
           props.allowMode !== EditorMode.Both ? "hidden" : ""
         }`}
       >
@@ -190,30 +191,34 @@ export default function TopBar(props: TopBarProps) {
       </div>
       {!props.isLogined
         ? (
-          <span className="leading-[30px] text-2xl font-medium text-center w-full">
-            {props.title}
-          </span>
+          <div className="flex items-center justify-center w-full">
+            <span className="leading-[30px] text-2xl font-medium text-center flex-1">
+              {props.title}
+            </span>
+            <ThemeToggle />
+          </div>
         )
         : null}
       {props.isLogined
         ? (
-          <div className="h-[30px] leading-[28px] box-border px-2 border border-gray-300 rounded">
+          <div className="h-[30px] leading-[28px] box-border px-2 border border-gray-300 dark:border-gray-700 rounded">
             <i
-              className="bi bi-box-arrow-left mr-4 text-base cursor-pointer h-4 w-4 hover:text-blue-600"
+              className="bi bi-box-arrow-left mr-4 text-base cursor-pointer h-4 w-4 hover:text-blue-600 dark:hover:text-blue-400"
               onClick={doLogout}
             />
             <i
-              className="bi bi-house-door mr-4 text-base cursor-pointer h-4 w-4 hover:text-blue-600"
+              className="bi bi-house-door mr-4 text-base cursor-pointer h-4 w-4 hover:text-blue-600 dark:hover:text-blue-400"
               onClick={goHome}
             />
             <i
-              className="bi bi-share mr-4 text-base cursor-pointer h-4 w-4 hover:text-blue-600"
+              className="bi bi-share mr-4 text-base cursor-pointer h-4 w-4 hover:text-blue-600 dark:hover:text-blue-400"
               onClick={showShare}
             />
             <i
-              className="bi bi-gear text-base cursor-pointer h-4 w-4 hover:text-blue-600"
+              className="bi bi-gear mr-4 text-base cursor-pointer h-4 w-4 hover:text-blue-600 dark:hover:text-blue-400"
               onClick={showSetting}
             />
+            <ThemeToggle />
           </div>
         )
         : null}

+ 6 - 2
islands/WelcomeFrame.tsx

@@ -1,5 +1,6 @@
 import { asset } from "fresh/runtime";
 import Button from "../components/form/Button.tsx";
+import ThemeToggle from "./ThemeToggle.tsx";
 
 export default function WelcomeFrame() {
   const goLogin = () => {
@@ -11,10 +12,13 @@ export default function WelcomeFrame() {
   };
 
   return (
-    <div className="flex items-center justify-center flex-col w-full h-full">
+    <div className="flex items-center justify-center flex-col w-full h-full relative">
+      <div className="absolute top-0 right-0">
+        <ThemeToggle />
+      </div>
       <div className="flex items-center">
         <img
-          className="w-32 h-32 mr-2"
+          className="w-32 h-32 mr-2 dark:invert"
           src={asset("/postdown.png")}
           alt="Postdown"
         />

+ 7 - 1
routes/_app.tsx

@@ -11,8 +11,14 @@ export default define.page(({ Component }: PageProps) => {
           href="https://unpkg.com/bootstrap-icons@1.10.4/font/bootstrap-icons.css"
           rel="stylesheet"
         />
+        <script
+          dangerouslySetInnerHTML={{
+            __html:
+              `(function(){var t=localStorage.getItem("theme");if(t==="dark"||(!t&&window.matchMedia("(prefers-color-scheme: dark)").matches)){document.documentElement.classList.add("dark")}})()`,
+          }}
+        />
       </head>
-      <body>
+      <body className="bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-100 transition-colors">
         <Modal />
         <Loading />
         <Component />

+ 2 - 1
routes/api/user/reset.tsx

@@ -17,7 +17,8 @@ export const handler = define.handlers({
       if (user.length > 0) {
         // Match old password
         if (
-          await getCryptoString(reqJson.old, "MD5") === user[0]["password"] as string
+          await getCryptoString(reqJson.old, "MD5") ===
+            user[0]["password"] as string
         ) {
           // Store new password
           update("User", tokenUserId, {

+ 19 - 0
static/markdown.css

@@ -10,3 +10,22 @@ table th, table td {
   border: 1px solid #d6d6d6;
   padding: 0.25rem 0.5rem;
 }
+
+/* Dark theme (applied via .dark-theme class on shadow root) */
+.dark-theme {
+  color: #f3f4f6;
+}
+.dark-theme pre {
+  background-color: #1e293b;
+  color: #e2e8f0;
+}
+.dark-theme table th, .dark-theme table td {
+  border-color: #4b5563;
+}
+.dark-theme a {
+  color: #60a5fa;
+}
+.dark-theme code {
+  background-color: #1e293b;
+  color: #e2e8f0;
+}

+ 31 - 8
tests/api/post_test.ts

@@ -18,7 +18,9 @@ function cleanup(dbPath: string) {
 }
 
 function makeCtx(method: string, body?: object, cookie?: string) {
-  const headers: Record<string, string> = { "Content-Type": "application/json" };
+  const headers: Record<string, string> = {
+    "Content-Type": "application/json",
+  };
   if (cookie) headers["Cookie"] = cookie;
   const initMethod = method === "GET" && body ? "POST" : method;
   const req = new Request("http://localhost", {
@@ -40,7 +42,10 @@ async function seedUserAndToken(email: string, password: string) {
     password: hashedPw,
   });
   const userId = user[0]["id"] as string | number;
-  const token = await getCryptoString("post-token-" + userId + Date.now(), "MD5");
+  const token = await getCryptoString(
+    "post-token-" + userId + Date.now(),
+    "MD5",
+  );
   insert("Token", { token, user_id: userId });
   return { userId, token };
 }
@@ -49,7 +54,10 @@ Deno.test("API post - create a new post", async () => {
   const dbPath = getTestDbPath();
   Deno.env.set("POSTDOWN_DB_PATH", dbPath);
   try {
-    const { token } = await seedUserAndToken("postcreate@example.com", "password");
+    const { token } = await seedUserAndToken(
+      "postcreate@example.com",
+      "password",
+    );
     const { handler } = await import("../../routes/api/post.tsx");
 
     const ctx = makeCtx("POST", {
@@ -88,7 +96,10 @@ Deno.test("API post - get own post", async () => {
   const dbPath = getTestDbPath();
   Deno.env.set("POSTDOWN_DB_PATH", dbPath);
   try {
-    const { userId, token } = await seedUserAndToken("postget@example.com", "password");
+    const { userId, token } = await seedUserAndToken(
+      "postget@example.com",
+      "password",
+    );
     insert("Post", {
       id: "test-post-1",
       title: "Test Post",
@@ -114,7 +125,10 @@ Deno.test("API post - get shared post without token", async () => {
   const dbPath = getTestDbPath();
   Deno.env.set("POSTDOWN_DB_PATH", dbPath);
   try {
-    const { userId } = await seedUserAndToken("postshared@example.com", "password");
+    const { userId } = await seedUserAndToken(
+      "postshared@example.com",
+      "password",
+    );
     insert("Post", {
       id: "shared-post-1",
       title: "Shared Post",
@@ -139,7 +153,10 @@ Deno.test("API post - get non-shared post without token returns error", async ()
   const dbPath = getTestDbPath();
   Deno.env.set("POSTDOWN_DB_PATH", dbPath);
   try {
-    const { userId } = await seedUserAndToken("postprivate@example.com", "password");
+    const { userId } = await seedUserAndToken(
+      "postprivate@example.com",
+      "password",
+    );
     insert("Post", {
       id: "private-post-1",
       title: "Private Post",
@@ -163,7 +180,10 @@ Deno.test("API post - update post title", async () => {
   const dbPath = getTestDbPath();
   Deno.env.set("POSTDOWN_DB_PATH", dbPath);
   try {
-    const { userId, token } = await seedUserAndToken("postupdate@example.com", "password");
+    const { userId, token } = await seedUserAndToken(
+      "postupdate@example.com",
+      "password",
+    );
     insert("Post", {
       id: "update-post-1",
       title: "Old Title",
@@ -211,7 +231,10 @@ Deno.test("API post - delete own post", async () => {
   const dbPath = getTestDbPath();
   Deno.env.set("POSTDOWN_DB_PATH", dbPath);
   try {
-    const { userId, token } = await seedUserAndToken("postdelete@example.com", "password");
+    const { userId, token } = await seedUserAndToken(
+      "postdelete@example.com",
+      "password",
+    );
     insert("Post", {
       id: "delete-post-1",
       title: "Delete Me",

+ 23 - 6
tests/api/share_test.ts

@@ -18,7 +18,9 @@ function cleanup(dbPath: string) {
 }
 
 function makeCtx(method: string, body?: object, cookie?: string) {
-  const headers: Record<string, string> = { "Content-Type": "application/json" };
+  const headers: Record<string, string> = {
+    "Content-Type": "application/json",
+  };
   if (cookie) headers["Cookie"] = cookie;
   const req = new Request("http://localhost", {
     method,
@@ -36,7 +38,10 @@ async function seedUserAndToken(email: string, password: string) {
     password: hashedPw,
   });
   const userId = user[0]["id"] as string | number;
-  const token = await getCryptoString("share-token-" + userId + Date.now(), "MD5");
+  const token = await getCryptoString(
+    "share-token-" + userId + Date.now(),
+    "MD5",
+  );
   insert("Token", { token, user_id: userId });
   return { userId, token };
 }
@@ -45,7 +50,10 @@ Deno.test("API share - share a post", async () => {
   const dbPath = getTestDbPath();
   Deno.env.set("POSTDOWN_DB_PATH", dbPath);
   try {
-    const { userId, token } = await seedUserAndToken("shareshare@example.com", "password");
+    const { userId, token } = await seedUserAndToken(
+      "shareshare@example.com",
+      "password",
+    );
     insert("Post", {
       id: "share-post-1",
       title: "Share Me",
@@ -75,7 +83,10 @@ Deno.test("API share - unshare a post", async () => {
   const dbPath = getTestDbPath();
   Deno.env.set("POSTDOWN_DB_PATH", dbPath);
   try {
-    const { userId, token } = await seedUserAndToken("shareunshare@example.com", "password");
+    const { userId, token } = await seedUserAndToken(
+      "shareunshare@example.com",
+      "password",
+    );
     insert("Post", {
       id: "unshare-post-1",
       title: "Unshare Me",
@@ -123,7 +134,10 @@ Deno.test("API share - share another user's post returns error", async () => {
   const dbPath = getTestDbPath();
   Deno.env.set("POSTDOWN_DB_PATH", dbPath);
   try {
-    const { userId: otherUserId } = await seedUserAndToken("shareother@example.com", "password");
+    const { userId: otherUserId } = await seedUserAndToken(
+      "shareother@example.com",
+      "password",
+    );
     insert("Post", {
       id: "other-post-1",
       title: "Other's Post",
@@ -132,7 +146,10 @@ Deno.test("API share - share another user's post returns error", async () => {
       shared: 0,
     });
 
-    const { token: myToken } = await seedUserAndToken("shareme@example.com", "password2");
+    const { token: myToken } = await seedUserAndToken(
+      "shareme@example.com",
+      "password2",
+    );
 
     const { handler } = await import("../../routes/api/share.tsx");
     const ctx = makeCtx("POST", {

+ 7 - 2
tests/api/user_test.ts

@@ -18,7 +18,9 @@ function cleanup(dbPath: string) {
 }
 
 function makeCtx(method: string, body?: object, cookie?: string) {
-  const headers: Record<string, string> = { "Content-Type": "application/json" };
+  const headers: Record<string, string> = {
+    "Content-Type": "application/json",
+  };
   if (cookie) headers["Cookie"] = cookie;
   const req = new Request("http://localhost", {
     method,
@@ -39,7 +41,10 @@ async function seedUser(email: string, password: string) {
 }
 
 async function seedToken(userId: string | number) {
-  const token = await getCryptoString("test-token-" + userId + Date.now(), "MD5");
+  const token = await getCryptoString(
+    "test-token-" + userId + Date.now(),
+    "MD5",
+  );
   insert("Token", { token, user_id: userId });
   return token;
 }

+ 10 - 2
tests/ui/button_test.tsx

@@ -1,4 +1,4 @@
-import { cleanup, assertEquals, render, screen, fireEvent } from "./setup.ts";
+import { assertEquals, cleanup, fireEvent, render, screen } from "./setup.ts";
 import Button from "../../components/form/Button.tsx";
 
 Deno.test({
@@ -97,7 +97,15 @@ Deno.test({
   name: "Button - fires onClick handler",
   fn() {
     let clicked = false;
-    render(<Button onClick={() => { clicked = true; }}>Clickable</Button>);
+    render(
+      <Button
+        onClick={() => {
+          clicked = true;
+        }}
+      >
+        Clickable
+      </Button>,
+    );
     fireEvent.click(screen.getByText("Clickable"));
     assertEquals(clicked, true);
     cleanup();

+ 8 - 2
tests/ui/checkbox_test.tsx

@@ -1,4 +1,4 @@
-import { cleanup, assertEquals, render, screen, fireEvent } from "./setup.ts";
+import { assertEquals, cleanup, fireEvent, render, screen } from "./setup.ts";
 import Checkbox from "../../components/form/Checkbox.tsx";
 
 Deno.test({
@@ -69,7 +69,13 @@ Deno.test({
   name: "Checkbox - fires onChange handler",
   fn() {
     let changed = false;
-    render(<Checkbox onChange={() => { changed = true; }} />);
+    render(
+      <Checkbox
+        onChange={() => {
+          changed = true;
+        }}
+      />,
+    );
     fireEvent.click(screen.getByRole("checkbox"));
     assertEquals(changed, true);
     cleanup();

+ 262 - 0
tests/ui/dark_theme_test.tsx

@@ -0,0 +1,262 @@
+import { act, assertEquals, cleanup, render, screen } from "./setup.ts";
+import Button from "../../components/form/Button.tsx";
+import Input from "../../components/form/Input.tsx";
+import Textarea from "../../components/form/Textarea.tsx";
+import Checkbox from "../../components/form/Checkbox.tsx";
+import Modal from "../../islands/Modal.tsx";
+import Loading from "../../islands/Loading.tsx";
+import PostList from "../../islands/PostList.tsx";
+
+// --- Button dark theme classes ---
+
+Deno.test({
+  name: "Dark theme - Button default variant has dark background class",
+  fn() {
+    render(<Button>Test</Button>);
+    const btn = screen.getByText("Test");
+    assertEquals(btn.className.includes("dark:bg-gray-700"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Dark theme - Button default variant has dark text class",
+  fn() {
+    render(<Button>Test</Button>);
+    const btn = screen.getByText("Test");
+    assertEquals(btn.className.includes("dark:text-gray-100"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Dark theme - Button has dark border class",
+  fn() {
+    render(<Button>Test</Button>);
+    const btn = screen.getByText("Test");
+    assertEquals(btn.className.includes("dark:border-gray-600"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Dark theme - Button primary variant does not override with dark bg",
+  fn() {
+    render(<Button variant="primary">Primary</Button>);
+    const btn = screen.getByText("Primary");
+    // Primary keeps blue-600 in both themes, should not have dark:bg-gray-700
+    assertEquals(btn.className.includes("bg-blue-600"), true);
+    assertEquals(btn.className.includes("dark:bg-gray-700"), false);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+// --- Input dark theme classes ---
+
+Deno.test({
+  name: "Dark theme - Input has dark background class",
+  fn() {
+    render(<Input placeholder="test" />);
+    const input = screen.getByPlaceholderText("test");
+    assertEquals(input.className.includes("dark:bg-gray-800"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Dark theme - Input has dark text class",
+  fn() {
+    render(<Input placeholder="test" />);
+    const input = screen.getByPlaceholderText("test");
+    assertEquals(input.className.includes("dark:text-gray-100"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Dark theme - Input has dark border class when no error",
+  fn() {
+    render(<Input placeholder="test" />);
+    const input = screen.getByPlaceholderText("test");
+    assertEquals(input.className.includes("dark:border-gray-600"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Dark theme - Input error state does not include dark border override",
+  fn() {
+    render(<Input error placeholder="err" />);
+    const input = screen.getByPlaceholderText("err");
+    assertEquals(input.className.includes("border-red-600"), true);
+    assertEquals(input.className.includes("dark:border-gray-600"), false);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+// --- Textarea dark theme classes ---
+
+Deno.test({
+  name: "Dark theme - Textarea has dark background class",
+  fn() {
+    render(<Textarea placeholder="text" />);
+    const ta = screen.getByPlaceholderText("text");
+    assertEquals(ta.className.includes("dark:bg-gray-800"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Dark theme - Textarea has dark text class",
+  fn() {
+    render(<Textarea placeholder="text" />);
+    const ta = screen.getByPlaceholderText("text");
+    assertEquals(ta.className.includes("dark:text-gray-100"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Dark theme - Textarea has dark border class when no error",
+  fn() {
+    render(<Textarea placeholder="text" />);
+    const ta = screen.getByPlaceholderText("text");
+    assertEquals(ta.className.includes("dark:border-gray-600"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+// --- Checkbox dark theme classes ---
+
+Deno.test({
+  name: "Dark theme - Checkbox label has dark text class",
+  fn() {
+    const { container } = render(<Checkbox label="Accept" />);
+    const span = container.querySelector("span")!;
+    assertEquals(span.className.includes("dark:text-gray-100"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+// --- Modal dark theme classes ---
+
+Deno.test({
+  name: "Dark theme - Modal panel has dark background class",
+  fn() {
+    const { container } = render(<Modal />);
+    act(() => {
+      globalThis.$modal!.show("Title", "Content", []);
+    });
+    const panel = container.querySelector(".bg-white")!;
+    assertEquals(panel.className.includes("dark:bg-gray-800"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Dark theme - Modal panel has dark border class",
+  fn() {
+    const { container } = render(<Modal />);
+    act(() => {
+      globalThis.$modal!.show("Title", "Content", []);
+    });
+    const panel = container.querySelector(".bg-white")!;
+    assertEquals(panel.className.includes("dark:border-gray-700"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Dark theme - Modal title border has dark class",
+  fn() {
+    const { container } = render(<Modal />);
+    act(() => {
+      globalThis.$modal!.show("Title", "Content", []);
+    });
+    const titleDiv = screen.getByText("Title");
+    assertEquals(titleDiv.className.includes("dark:border-gray-700"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+// --- Loading dark theme classes ---
+
+Deno.test({
+  name: "Dark theme - Loading spinner has dark background class",
+  fn() {
+    const { container } = render(<Loading />);
+    const spinner = container.querySelector(".bg-white")!;
+    assertEquals(spinner.className.includes("dark:bg-gray-200"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+// --- PostList dark theme classes ---
+
+Deno.test({
+  name: "Dark theme - PostList card has dark border class",
+  fn() {
+    const posts = [{
+      id: "1",
+      title: "Test",
+      content: "Content",
+      shared: false,
+    }];
+    const { container } = render(<PostList posts={posts} />);
+    const card = container.querySelector(".grid > div")!;
+    assertEquals(card.className.includes("dark:border-gray-700"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "Dark theme - PostList card has dark background class",
+  fn() {
+    const posts = [{
+      id: "1",
+      title: "Test",
+      content: "Content",
+      shared: false,
+    }];
+    const { container } = render(<PostList posts={posts} />);
+    const card = container.querySelector(".grid > div")!;
+    assertEquals(card.className.includes("dark:bg-gray-800"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});

+ 1 - 1
tests/ui/input_test.tsx

@@ -1,4 +1,4 @@
-import { cleanup, assertEquals, render, screen } from "./setup.ts";
+import { assertEquals, cleanup, render, screen } from "./setup.ts";
 import Input from "../../components/form/Input.tsx";
 
 Deno.test({

+ 1 - 1
tests/ui/loading_test.tsx

@@ -1,4 +1,4 @@
-import { cleanup, assertEquals, render, act } from "./setup.ts";
+import { act, assertEquals, cleanup, render } from "./setup.ts";
 import Loading from "../../islands/Loading.tsx";
 
 Deno.test({

+ 17 - 5
tests/ui/login_frame_test.tsx

@@ -1,4 +1,11 @@
-import { cleanup, assertEquals, render, screen, fireEvent, act } from "./setup.ts";
+import {
+  act,
+  assertEquals,
+  cleanup,
+  fireEvent,
+  render,
+  screen,
+} from "./setup.ts";
 import LoginFrame from "../../islands/LoginFrame.tsx";
 
 // Mock fetch to prevent actual network calls
@@ -6,9 +13,11 @@ const originalFetch = globalThis.fetch;
 
 function mockFetch(response = { success: false }) {
   globalThis.fetch = () =>
-    Promise.resolve(new Response(JSON.stringify(response), {
-      headers: { "Content-Type": "application/json" },
-    }));
+    Promise.resolve(
+      new Response(JSON.stringify(response), {
+        headers: { "Content-Type": "application/json" },
+      }),
+    );
 }
 
 function restoreFetch() {
@@ -88,7 +97,10 @@ Deno.test({
   fn() {
     mockFetch();
     render(<LoginFrame mode="register" />);
-    assertEquals(screen.getByPlaceholderText("Confirm your password") !== null, true);
+    assertEquals(
+      screen.getByPlaceholderText("Confirm your password") !== null,
+      true,
+    );
     cleanup();
     restoreFetch();
   },

+ 14 - 2
tests/ui/modal_test.tsx

@@ -1,4 +1,11 @@
-import { cleanup, assertEquals, render, screen, fireEvent, act } from "./setup.ts";
+import {
+  act,
+  assertEquals,
+  cleanup,
+  fireEvent,
+  render,
+  screen,
+} from "./setup.ts";
 import Modal from "../../islands/Modal.tsx";
 
 Deno.test({
@@ -68,7 +75,12 @@ Deno.test({
     render(<Modal />);
     act(() => {
       globalThis.$modal!.show("Title", "Content", [
-        { text: "Confirm", onClick: (text: string) => { clicked = text; } },
+        {
+          text: "Confirm",
+          onClick: (text: string) => {
+            clicked = text;
+          },
+        },
       ]);
     });
     fireEvent.click(screen.getByText("Confirm"));

+ 12 - 4
tests/ui/page_container_test.tsx

@@ -1,10 +1,14 @@
-import { cleanup, assertEquals, render, screen } from "./setup.ts";
+import { assertEquals, cleanup, render, screen } from "./setup.ts";
 import PageContainer from "../../components/layout/PageContainer.tsx";
 
 Deno.test({
   name: "PageContainer - renders children",
   fn() {
-    render(<PageContainer><span>Child content</span></PageContainer>);
+    render(
+      <PageContainer>
+        <span>Child content</span>
+      </PageContainer>,
+    );
     assertEquals(screen.getByText("Child content").tagName, "SPAN");
     cleanup();
   },
@@ -30,7 +34,9 @@ Deno.test({
 Deno.test({
   name: "PageContainer - applies centering classes when centered=true",
   fn() {
-    const { container } = render(<PageContainer centered>Centered</PageContainer>);
+    const { container } = render(
+      <PageContainer centered>Centered</PageContainer>,
+    );
     const div = container.firstElementChild!;
     assertEquals(div.className.includes("items-center"), true);
     assertEquals(div.className.includes("justify-center"), true);
@@ -56,7 +62,9 @@ Deno.test({
 Deno.test({
   name: "PageContainer - appends custom className",
   fn() {
-    const { container } = render(<PageContainer className="my-page">Custom</PageContainer>);
+    const { container } = render(
+      <PageContainer className="my-page">Custom</PageContainer>,
+    );
     const div = container.firstElementChild!;
     assertEquals(div.className.includes("my-page"), true);
     cleanup();

+ 1 - 1
tests/ui/post_list_test.tsx

@@ -1,4 +1,4 @@
-import { cleanup, assertEquals, render, screen, fireEvent } from "./setup.ts";
+import { assertEquals, cleanup, fireEvent, render, screen } from "./setup.ts";
 import PostList from "../../islands/PostList.tsx";
 
 const mockPosts = [

+ 7 - 1
tests/ui/setup.ts

@@ -2,6 +2,12 @@
 import "./dom_shim.ts";
 
 // Re-export testing utilities
-export { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact";
+export {
+  cleanup,
+  fireEvent,
+  render,
+  screen,
+  waitFor,
+} from "@testing-library/preact";
 export { act } from "preact/test-utils";
 export { assertEquals, assertExists, assertStringIncludes } from "@std/assert";

+ 4 - 2
tests/ui/textarea_test.tsx

@@ -1,4 +1,4 @@
-import { cleanup, assertEquals, render, screen } from "./setup.ts";
+import { assertEquals, cleanup, render, screen } from "./setup.ts";
 import Textarea from "../../components/form/Textarea.tsx";
 
 Deno.test({
@@ -56,7 +56,9 @@ Deno.test({
   name: "Textarea - passes through disabled attribute",
   fn() {
     render(<Textarea disabled placeholder="disabled" />);
-    const textarea = screen.getByPlaceholderText("disabled") as HTMLTextAreaElement;
+    const textarea = screen.getByPlaceholderText(
+      "disabled",
+    ) as HTMLTextAreaElement;
     assertEquals(textarea.disabled, true);
     cleanup();
   },

+ 202 - 0
tests/ui/theme_toggle_test.tsx

@@ -0,0 +1,202 @@
+import { act, assertEquals, cleanup, fireEvent, render } from "./setup.ts";
+import ThemeToggle from "../../islands/ThemeToggle.tsx";
+
+function resetState() {
+  document.documentElement.classList.remove("dark");
+  localStorage.removeItem("theme");
+}
+
+Deno.test({
+  name: "ThemeToggle - renders moon icon in light mode by default",
+  fn() {
+    resetState();
+    const { container } = render(<ThemeToggle />);
+    const icon = container.querySelector("i")!;
+    assertEquals(icon.classList.contains("bi-moon-stars"), true);
+    assertEquals(icon.classList.contains("bi-sun"), false);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "ThemeToggle - renders sun icon when dark class is present",
+  fn() {
+    resetState();
+    document.documentElement.classList.add("dark");
+    let container: HTMLElement;
+    act(() => {
+      ({ container } = render(<ThemeToggle />));
+    });
+    const icon = container!.querySelector("i")!;
+    assertEquals(icon.classList.contains("bi-sun"), true);
+    assertEquals(icon.classList.contains("bi-moon-stars"), false);
+    cleanup();
+    resetState();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "ThemeToggle - click adds dark class to documentElement",
+  fn() {
+    resetState();
+    const { container } = render(<ThemeToggle />);
+    const icon = container.querySelector("i")!;
+    act(() => {
+      fireEvent.click(icon);
+    });
+    assertEquals(document.documentElement.classList.contains("dark"), true);
+    cleanup();
+    resetState();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "ThemeToggle - click toggles icon from moon to sun",
+  fn() {
+    resetState();
+    const { container } = render(<ThemeToggle />);
+    const icon = container.querySelector("i")!;
+    assertEquals(icon.classList.contains("bi-moon-stars"), true);
+    act(() => {
+      fireEvent.click(icon);
+    });
+    const updatedIcon = container.querySelector("i")!;
+    assertEquals(updatedIcon.classList.contains("bi-sun"), true);
+    assertEquals(updatedIcon.classList.contains("bi-moon-stars"), false);
+    cleanup();
+    resetState();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "ThemeToggle - second click removes dark class",
+  fn() {
+    resetState();
+    const { container } = render(<ThemeToggle />);
+    const icon = container.querySelector("i")!;
+    act(() => {
+      fireEvent.click(icon);
+    });
+    assertEquals(document.documentElement.classList.contains("dark"), true);
+    act(() => {
+      fireEvent.click(container.querySelector("i")!);
+    });
+    assertEquals(document.documentElement.classList.contains("dark"), false);
+    cleanup();
+    resetState();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "ThemeToggle - click persists 'dark' to localStorage",
+  fn() {
+    resetState();
+    const { container } = render(<ThemeToggle />);
+    act(() => {
+      fireEvent.click(container.querySelector("i")!);
+    });
+    assertEquals(localStorage.getItem("theme"), "dark");
+    cleanup();
+    resetState();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "ThemeToggle - second click persists 'light' to localStorage",
+  fn() {
+    resetState();
+    const { container } = render(<ThemeToggle />);
+    act(() => {
+      fireEvent.click(container.querySelector("i")!);
+    });
+    act(() => {
+      fireEvent.click(container.querySelector("i")!);
+    });
+    assertEquals(localStorage.getItem("theme"), "light");
+    cleanup();
+    resetState();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "ThemeToggle - click dispatches ThemeChange event",
+  fn() {
+    resetState();
+    let eventDetail: boolean | null = null;
+    const listener = (e: Event) => {
+      eventDetail = (e as CustomEvent).detail;
+    };
+    document.addEventListener("ThemeChange", listener);
+
+    const { container } = render(<ThemeToggle />);
+    act(() => {
+      fireEvent.click(container.querySelector("i")!);
+    });
+    assertEquals(eventDetail, true);
+
+    document.removeEventListener("ThemeChange", listener);
+    cleanup();
+    resetState();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "ThemeToggle - has hover classes for both themes",
+  fn() {
+    resetState();
+    const { container } = render(<ThemeToggle />);
+    const icon = container.querySelector("i")!;
+    assertEquals(icon.className.includes("hover:text-blue-600"), true);
+    assertEquals(icon.className.includes("dark:hover:text-blue-400"), true);
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "ThemeToggle - has correct title attribute in light mode",
+  fn() {
+    resetState();
+    const { container } = render(<ThemeToggle />);
+    const icon = container.querySelector("i")!;
+    assertEquals(icon.getAttribute("title"), "Switch to dark mode");
+    cleanup();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});
+
+Deno.test({
+  name: "ThemeToggle - has correct title attribute in dark mode",
+  fn() {
+    resetState();
+    document.documentElement.classList.add("dark");
+    let container: HTMLElement;
+    act(() => {
+      ({ container } = render(<ThemeToggle />));
+    });
+    const icon = container!.querySelector("i")!;
+    assertEquals(icon.getAttribute("title"), "Switch to light mode");
+    cleanup();
+    resetState();
+  },
+  sanitizeResources: false,
+  sanitizeOps: false,
+});

+ 49 - 7
tests/utils/db_test.ts

@@ -22,7 +22,13 @@ Deno.test("db - insert and find a User", () => {
     assertEquals(result.length, 1);
     assertExists(result[0]["id"]);
 
-    const found = find("User", { email: "test@example.com" }, ["name", "email"], undefined, TEST_DB_PATH);
+    const found = find(
+      "User",
+      { email: "test@example.com" },
+      ["name", "email"],
+      undefined,
+      TEST_DB_PATH,
+    );
     assertEquals(found.length, 1);
     assertEquals(found[0]["name"], "testuser");
     assertEquals(found[0]["email"], "test@example.com");
@@ -34,7 +40,13 @@ Deno.test("db - insert and find a User", () => {
 Deno.test("db - find with no results returns empty array", () => {
   cleanup();
   try {
-    const found = find("User", { email: "nonexistent@example.com" }, ["id"], undefined, TEST_DB_PATH);
+    const found = find(
+      "User",
+      { email: "nonexistent@example.com" },
+      ["id"],
+      undefined,
+      TEST_DB_PATH,
+    );
     assertEquals(found.length, 0);
   } finally {
     cleanup();
@@ -50,7 +62,13 @@ Deno.test("db - find with targetKeys returns only specified columns", () => {
       password: "hashedpassword",
     }, TEST_DB_PATH);
 
-    const found = find("User", { email: "test@example.com" }, ["name"], undefined, TEST_DB_PATH);
+    const found = find(
+      "User",
+      { email: "test@example.com" },
+      ["name"],
+      undefined,
+      TEST_DB_PATH,
+    );
     assertEquals(found.length, 1);
     assertEquals(Object.keys(found[0]).length, 1);
     assertEquals(found[0]["name"], "testuser");
@@ -93,7 +111,13 @@ Deno.test("db - update a User record", () => {
     const result = update("User", userId, { name: "newname" }, TEST_DB_PATH);
     assertEquals(result.length, 1);
 
-    const found = find("User", { id: userId }, ["name"], undefined, TEST_DB_PATH);
+    const found = find(
+      "User",
+      { id: userId },
+      ["name"],
+      undefined,
+      TEST_DB_PATH,
+    );
     assertEquals(found[0]["name"], "newname");
   } finally {
     cleanup();
@@ -133,7 +157,13 @@ Deno.test("db - insert and find a Post", () => {
     assertEquals(result.length, 1);
     assertEquals(result[0]["id"], "abc123");
 
-    const found = find("Post", { id: "abc123" }, ["title", "content"], undefined, TEST_DB_PATH);
+    const found = find(
+      "Post",
+      { id: "abc123" },
+      ["title", "content"],
+      undefined,
+      TEST_DB_PATH,
+    );
     assertEquals(found.length, 1);
     assertEquals(found[0]["title"], "Test Post");
     assertEquals(found[0]["content"], "# Hello World");
@@ -151,7 +181,13 @@ Deno.test("db - insert and find a Token", () => {
     }, TEST_DB_PATH);
     assertEquals(result.length, 1);
 
-    const found = find("Token", { token: "test-token-123" }, ["user_id"], undefined, TEST_DB_PATH);
+    const found = find(
+      "Token",
+      { token: "test-token-123" },
+      ["user_id"],
+      undefined,
+      TEST_DB_PATH,
+    );
     assertEquals(found.length, 1);
     assertEquals(found[0]["user_id"], 1);
   } finally {
@@ -172,7 +208,13 @@ Deno.test("db - update Post shared status", () => {
 
     update("Post", "share-test", { shared: 1 }, TEST_DB_PATH);
 
-    const found = find("Post", { id: "share-test" }, ["shared"], undefined, TEST_DB_PATH);
+    const found = find(
+      "Post",
+      { id: "share-test" },
+      ["shared"],
+      undefined,
+      TEST_DB_PATH,
+    );
     assertEquals(found[0]["shared"], 1);
   } finally {
     cleanup();