import React, { createContext, useContext, useMemo, useState, useCallback, useEffect, } from 'react'; interface SidebarContextValue { collapsed: boolean; setCollapsed: (value: boolean) => void; neutralHue: number; setNeutralHue: (value: number | string) => void; resetNeutralHue: () => void; neutralChroma: number; setNeutralChroma: (value: number | string) => void; resetNeutralChroma: () => void; neutralContrast: number; setNeutralContrast: (value: number | string) => void; resetNeutralContrast: () => void; themeMode: ThemeMode; setThemeMode: (mode: ThemeMode) => void; cycleThemeMode: () => void; themeModes: ThemeMode[]; } type ThemeMode = 'system' | 'light' | 'dark'; const SIDEBAR_COLLAPSE_STORAGE_KEY = 'papercrate_sidebar_collapsed'; const SidebarContext = createContext(null); const THEME_STORAGE_KEY = 'papercrate_theme_settings'; const DEFAULT_NEUTRAL_HUE = 29; const DEFAULT_NEUTRAL_CHROMA = 0.44; const DEFAULT_NEUTRAL_CONTRAST = 0.56; const DEFAULT_THEME_MODE = 'system'; const THEME_MODES = ['system', 'light', 'dark']; const loadInitialThemeSettings = () => { const defaults = { neutralHue: DEFAULT_NEUTRAL_HUE, neutralChroma: DEFAULT_NEUTRAL_CHROMA, neutralContrast: DEFAULT_NEUTRAL_CONTRAST, mode: DEFAULT_THEME_MODE, }; const root = document.documentElement; const readNumberVar = (name, fallback) => { const inlineValue = root.style.getPropertyValue(name); const inlineParsed = Number.parseFloat(inlineValue); if (!Number.isNaN(inlineParsed)) { return inlineParsed; } const computedValue = window.getComputedStyle(root).getPropertyValue(name); const computedParsed = Number.parseFloat(computedValue); if (!Number.isNaN(computedParsed)) { return computedParsed; } return fallback; }; const loadFromRoot = () => { const current = root.style.getPropertyValue('--neutral-hue'); const parsed = Number.parseInt(current, 10); return { neutralHue: Number.isNaN(parsed) ? defaults.neutralHue : parsed, neutralChroma: readNumberVar('--neutral-chroma', defaults.neutralChroma), neutralContrast: readNumberVar('--neutral-contrast', defaults.neutralContrast), }; }; const rootValues = loadFromRoot(); let neutralHueValue = rootValues.neutralHue; let neutralChromaValue = rootValues.neutralChroma; let neutralContrastValue = rootValues.neutralContrast; let modeValue = defaults.mode; const composite = window.localStorage.getItem(THEME_STORAGE_KEY); if (composite) { try { const parsed = JSON.parse(composite); const storedHue = Number.parseInt(parsed?.neutralHue, 10); if (!Number.isNaN(storedHue)) { neutralHueValue = storedHue; } const storedChroma = Number.parseFloat(parsed?.neutralChroma); if (!Number.isNaN(storedChroma)) { neutralChromaValue = Math.min(Math.max(storedChroma, 0), 1); } const storedContrast = Number.parseFloat(parsed?.neutralContrast); if (!Number.isNaN(storedContrast)) { neutralContrastValue = Math.min(Math.max(storedContrast, 0), 1); } const storedMode = parsed?.mode; if (THEME_MODES.includes(storedMode)) { modeValue = storedMode; } } catch (error) { console.warn('[theme] failed to parse stored theme settings', error); } } else { const legacyHue = window.localStorage.getItem('papercrate_neutral_hue'); if (legacyHue) { const parsedLegacyHue = Number.parseInt(legacyHue, 10); if (!Number.isNaN(parsedLegacyHue)) { neutralHueValue = parsedLegacyHue; } } const legacyMode = window.localStorage.getItem('papercrate_theme_mode'); if (THEME_MODES.includes(legacyMode)) { modeValue = legacyMode; } } return { neutralHue: neutralHueValue, neutralChroma: neutralChromaValue, neutralContrast: neutralContrastValue, mode: modeValue, }; }; const loadInitialCollapsedState = (defaultValue) => { try { const stored = window.sessionStorage.getItem(SIDEBAR_COLLAPSE_STORAGE_KEY); if (stored === '1' || stored === 'true') { return true; } if (stored === '0' || stored === 'false') { return false; } } catch (error) { console.warn('[sidebar] failed to read collapse state', error); } return Boolean(defaultValue); }; export const SidebarProvider = ({ initialCollapsed = false, children }) => { const [collapsed, setCollapsedState] = useState(() => loadInitialCollapsedState(initialCollapsed)); const initialTheme = useMemo(() => loadInitialThemeSettings(), []); const [neutralHue, setNeutralHueState] = useState(initialTheme.neutralHue); const [neutralChroma, setNeutralChromaState] = useState(initialTheme.neutralChroma); const [neutralContrast, setNeutralContrastState] = useState(initialTheme.neutralContrast); const [themeMode, setThemeModeState] = useState(initialTheme.mode); useEffect(() => { document.documentElement.style.setProperty('--neutral-hue', `${neutralHue}deg`); }, [neutralHue]); useEffect(() => { document.documentElement.style.setProperty('--neutral-chroma', String(neutralChroma)); }, [neutralChroma]); useEffect(() => { document.documentElement.style.setProperty('--neutral-contrast', String(neutralContrast)); }, [neutralContrast]); useEffect(() => { const root = document.documentElement; if (themeMode === 'system') { root.removeAttribute('data-theme'); } else { root.setAttribute('data-theme', themeMode); } }, [themeMode]); useEffect(() => { try { const payload = JSON.stringify({ neutralHue, neutralChroma, neutralContrast, mode: themeMode, }); window.localStorage.setItem(THEME_STORAGE_KEY, payload); window.localStorage.removeItem('papercrate_neutral_hue'); window.localStorage.removeItem('papercrate_theme_mode'); } catch (error) { console.warn('[theme] failed to persist theme settings', error); } }, [neutralHue, neutralChroma, neutralContrast, themeMode]); const setNeutralHue = useCallback((value) => { setNeutralHueState((prev) => { if (value === '' || value === null || value === undefined) { return DEFAULT_NEUTRAL_HUE; } const parsed = Number(value); if (Number.isNaN(parsed)) { return prev; } const clamped = Math.min(Math.max(Math.round(parsed), 0), 360); return clamped; }); }, []); const setNeutralChroma = useCallback((value) => { setNeutralChromaState((prev) => { if (value === '' || value === null || value === undefined) { return DEFAULT_NEUTRAL_CHROMA; } const parsed = Number(value); if (Number.isNaN(parsed)) { return prev; } const clamped = Math.min(Math.max(parsed, 0), 1); return Math.round(clamped * 1000) / 1000; }); }, []); const resetNeutralChroma = useCallback(() => { setNeutralChromaState(DEFAULT_NEUTRAL_CHROMA); }, []); const setNeutralContrast = useCallback((value) => { setNeutralContrastState((prev) => { if (value === '' || value === null || value === undefined) { return DEFAULT_NEUTRAL_CONTRAST; } const parsed = Number(value); if (Number.isNaN(parsed)) { return prev; } const clamped = Math.min(Math.max(parsed, 0), 1); return Math.round(clamped * 1000) / 1000; }); }, []); const resetNeutralContrast = useCallback(() => { setNeutralContrastState(DEFAULT_NEUTRAL_CONTRAST); }, []); const resetNeutralHue = useCallback(() => { setNeutralHueState(DEFAULT_NEUTRAL_HUE); }, []); const setThemeMode = useCallback((mode) => { if (!THEME_MODES.includes(mode)) { return; } setThemeModeState(mode); }, []); const cycleThemeMode = useCallback(() => { const index = THEME_MODES.indexOf(themeMode); const nextIndex = index === -1 ? 0 : (index + 1) % THEME_MODES.length; setThemeModeState(THEME_MODES[nextIndex]); }, [themeMode]); useEffect(() => { try { window.sessionStorage.setItem(SIDEBAR_COLLAPSE_STORAGE_KEY, collapsed ? '1' : '0'); } catch (error) { console.warn('[sidebar] failed to persist collapse state', error); } }, [collapsed]); const setCollapsed = useCallback((value: boolean) => { setCollapsedState(Boolean(value)); }, []); const contextValue = useMemo( () => ({ collapsed, setCollapsed, neutralHue, setNeutralHue, neutralChroma, setNeutralChroma, neutralContrast, setNeutralContrast, resetNeutralHue, resetNeutralChroma, resetNeutralContrast, themeMode, setThemeMode, cycleThemeMode, themeModes: THEME_MODES, defaultNeutralHue: DEFAULT_NEUTRAL_HUE, defaultNeutralChroma: DEFAULT_NEUTRAL_CHROMA, defaultNeutralContrast: DEFAULT_NEUTRAL_CONTRAST, }), [ collapsed, setCollapsed, neutralHue, setNeutralHue, neutralChroma, setNeutralChroma, neutralContrast, setNeutralContrast, resetNeutralHue, resetNeutralChroma, resetNeutralContrast, themeMode, setThemeMode, cycleThemeMode, ], ); return {children}; }; export const useSidebarContext = () => { const context = useContext(SidebarContext); if (!context) { throw new Error('useSidebarContext must be used within a SidebarProvider'); } return context; }; export const useSidebarControls = () => { const { setCollapsed } = useSidebarContext(); const openSidebar = useCallback(() => setCollapsed(false), [setCollapsed]); const closeSidebar = useCallback(() => setCollapsed(true), [setCollapsed]); return { openSidebar, closeSidebar }; }; export default SidebarContext;