소스 검색

fix(studio): persist manual theme selection

Ma 7 달 전
부모
커밋
b98bc78277
2개의 변경된 파일과 90개의 추가작업 그리고 6개의 파일을 삭제
  1. 31 0
      packages/studio/src/hooks/use-theme.test.ts
  2. 59 6
      packages/studio/src/hooks/use-theme.ts

+ 31 - 0
packages/studio/src/hooks/use-theme.test.ts

@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { getTimeBasedThemeForHour, readStoredTheme, resolveThemePreference } from "./use-theme";
+
+describe("resolveThemePreference", () => {
+  it("keeps a stored manual light theme during night hours", () => {
+    expect(resolveThemePreference({ hour: 23, storedTheme: "light" })).toBe("light");
+  });
+
+  it("falls back to the clock when no manual theme is stored", () => {
+    expect(resolveThemePreference({ hour: 23, storedTheme: null })).toBe("dark");
+    expect(resolveThemePreference({ hour: 9, storedTheme: null })).toBe("light");
+  });
+});
+
+describe("getTimeBasedThemeForHour", () => {
+  it("switches at 6:00 and 18:00", () => {
+    expect(getTimeBasedThemeForHour(5)).toBe("dark");
+    expect(getTimeBasedThemeForHour(6)).toBe("light");
+    expect(getTimeBasedThemeForHour(17)).toBe("light");
+    expect(getTimeBasedThemeForHour(18)).toBe("dark");
+  });
+});
+
+describe("readStoredTheme", () => {
+  it("accepts only light and dark values from storage", () => {
+    expect(readStoredTheme({ getItem: () => "light" })).toBe("light");
+    expect(readStoredTheme({ getItem: () => "dark" })).toBe("dark");
+    expect(readStoredTheme({ getItem: () => "auto" })).toBeNull();
+    expect(readStoredTheme({ getItem: () => null })).toBeNull();
+  });
+});

+ 59 - 6
packages/studio/src/hooks/use-theme.ts

@@ -1,22 +1,75 @@
-import { useState, useEffect } from "react";
+import { useEffect, useState } from "react";
 
 export type Theme = "light" | "dark";
 
-function getTimeBasedTheme(): Theme {
-  const hour = new Date().getHours();
+const THEME_STORAGE_KEY = "inkos:studio:theme";
+
+interface ThemeStorageLike {
+  getItem(key: string): string | null;
+  setItem(key: string, value: string): void;
+}
+
+export function getTimeBasedThemeForHour(hour: number): Theme {
   return hour >= 6 && hour < 18 ? "light" : "dark";
 }
 
+function getTimeBasedTheme(): Theme {
+  return getTimeBasedThemeForHour(new Date().getHours());
+}
+
+function getThemeStorage(): ThemeStorageLike | null {
+  if (typeof window === "undefined") {
+    return null;
+  }
+
+  try {
+    return window.localStorage;
+  } catch {
+    return null;
+  }
+}
+
+export function readStoredTheme(storage: Pick<ThemeStorageLike, "getItem"> | null | undefined): Theme | null {
+  const storedTheme = storage?.getItem(THEME_STORAGE_KEY);
+  return storedTheme === "light" || storedTheme === "dark" ? storedTheme : null;
+}
+
+export function resolveThemePreference(params: {
+  readonly hour: number;
+  readonly storedTheme: Theme | null;
+}): Theme {
+  return params.storedTheme ?? getTimeBasedThemeForHour(params.hour);
+}
+
 export function useTheme() {
-  const [theme, setTheme] = useState<Theme>(getTimeBasedTheme);
+  const [theme, setThemeState] = useState<Theme>(() =>
+    resolveThemePreference({
+      hour: new Date().getHours(),
+      storedTheme: readStoredTheme(getThemeStorage()),
+    }),
+  );
 
   useEffect(() => {
-    // Check every minute for time-based switch
     const timer = setInterval(() => {
-      setTheme(getTimeBasedTheme());
+      const storedTheme = readStoredTheme(getThemeStorage());
+      setThemeState(resolveThemePreference({
+        hour: new Date().getHours(),
+        storedTheme,
+      }));
     }, 60000);
+
     return () => clearInterval(timer);
   }, []);
 
+  const setTheme = (nextTheme: Theme) => {
+    const storage = getThemeStorage();
+    try {
+      storage?.setItem(THEME_STORAGE_KEY, nextTheme);
+    } catch {
+      // Ignore storage failures and keep the in-memory preference for this session.
+    }
+    setThemeState(nextTheme);
+  };
+
   return { theme, setTheme };
 }