Files
TUIkit/docs/app/components/ThemeProvider.tsx
T
phranck bdb6d0c3f9 Chore: Fourth-pass optimization — bug fix, auto test counts, ESLint clean, constant extraction
- Fix: parseTerminalFormatting used undefined 'key' instead of 'partKey' (TS error)
- Fix: ESLint setState-in-effect errors (4→0) via cleanup patterns and hydration-sync
- Feat: Auto test/suite counts from Swift project at build-time (prebuild script + env vars)
- Refactor: ArchHighlight sub-component eliminates 4× duplicated pattern in page.tsx
- Refactor: BTN_PRIMARY/BTN_SECONDARY constants replace duplicated button classes
- Refactor: CRT geometry object centralizes repeated calc() layer positions
- Refactor: 13 magic numbers → named constants in TerminalScreen (timings, delays)
- Refactor: Icon component extended with custom SVG support (copy icon)
- Refactor: cloudGradient() helper replaces 6× duplicated radial-gradient strings
- Refactor: HIGHLIGHT constants object for syntax highlighter colors
- Refactor: text-glow CSS utility replaces duplicated textShadow inline styles
2026-02-02 15:38:33 +01:00

111 lines
3.6 KiB
TypeScript

"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
/** Available phosphor themes matching TUIkit's built-in palettes. */
export const themes = ["green", "amber", "red", "violet", "blue", "white"] as const;
export type Theme = (typeof themes)[number];
interface ThemeContextValue {
/** Current theme, or null while hydrating (before localStorage is read). */
theme: Theme | null;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue>({
theme: null,
setTheme: () => {},
});
const STORAGE_KEY = "tuikit-theme";
/** Type guard: validates that a string is a known theme name. */
function isTheme(value: string | null): value is Theme {
return !!value && (themes as readonly string[]).includes(value);
}
/**
* Reads the persisted theme from localStorage (set by the blocking script
* in layout.tsx), then checks the DOM attribute as fallback.
* Returns "green" on first visit or during SSR.
*
* NOTE: The valid theme list here must match the blocking script in layout.tsx.
*/
function getInitialTheme(): Theme {
if (typeof window !== "undefined") {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (isTheme(stored)) return stored;
} catch { /* localStorage unavailable */ }
const attr = document.documentElement.getAttribute("data-theme");
if (isTheme(attr)) return attr;
}
return "green";
}
/** Provides theme state and applies it to the document root via data-theme attribute. */
export function ThemeProvider({ children }: { children: ReactNode }) {
/**
* Start with null on both server and client to avoid hydration mismatch.
* The blocking script in layout.tsx already sets data-theme on <html> before
* first paint, so there is no FOUC. React state catches up in useEffect below.
*/
const [theme, setThemeState] = useState<Theme | null>(null);
/**
* After hydration, read the actual theme from DOM/localStorage.
* This is a legitimate hydration-sync pattern: the server cannot know
* which theme the user has stored in localStorage, so we must read it
* on the client and update React state to match reality.
*/
useEffect(() => {
const actual = getInitialTheme();
// eslint-disable-next-line react-hooks/set-state-in-effect -- Hydration sync: server cannot know the persisted theme
setThemeState(actual);
document.documentElement.setAttribute("data-theme", actual);
}, []);
const setTheme = useCallback((next: Theme) => {
setThemeState(next);
document.documentElement.setAttribute("data-theme", next);
try { localStorage.setItem(STORAGE_KEY, next); } catch { /* quota exceeded or blocked */ }
}, []);
/** Cycle to the next theme when "t" is pressed (skip if user is typing in an input). */
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key !== "t") return;
if (theme === null) return;
const target = event.target;
if (target instanceof HTMLElement) {
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) return;
}
const currentIndex = themes.indexOf(theme);
const nextIndex = (currentIndex + 1) % themes.length;
setTheme(themes[nextIndex]);
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [theme, setTheme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
/** Hook to access the current theme and setter. */
export function useTheme() {
return useContext(ThemeContext);
}