From b987bdacfef871902de46f8eef471cf74c2a2108 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 11 Nov 2025 22:38:40 +0100 Subject: [PATCH] theme --- frontend/src/sidebar/Sidebar.jsx | 87 +++++++++++++++- frontend/src/sidebar/SidebarContext.js | 133 +++++++++++++++++++++++-- frontend/src/styles/base/theme.css | 74 +++++++------- 3 files changed, 246 insertions(+), 48 deletions(-) diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx index 96e68bb..f0eddf5 100644 --- a/frontend/src/sidebar/Sidebar.jsx +++ b/frontend/src/sidebar/Sidebar.jsx @@ -195,6 +195,12 @@ const Sidebar = ({ neutralHue, setNeutralHue, resetNeutralHue, + neutralChroma, + setNeutralChroma, + resetNeutralChroma, + neutralContrast, + setNeutralContrast, + resetNeutralContrast, themeMode, cycleThemeMode, themeModes, @@ -204,6 +210,8 @@ const Sidebar = ({ setCollapsed(true); }, [setCollapsed]); const neutralHueInputId = useId(); + const neutralChromaInputId = useId(); + const neutralContrastInputId = useId(); const sortedCorrespondents = useMemo(() => { if (!Array.isArray(correspondents)) { return []; @@ -281,9 +289,11 @@ const Sidebar = ({ [onUploadFiles, selectedFolder], ); - const handleNeutralHueReset = useCallback(() => { + const handleThemeAdjustmentsReset = useCallback(() => { resetNeutralHue(); - }, [resetNeutralHue]); + resetNeutralChroma(); + resetNeutralContrast(); + }, [resetNeutralHue, resetNeutralChroma, resetNeutralContrast]); const handleNeutralHueChange = useCallback( (value) => { @@ -300,6 +310,44 @@ const Sidebar = ({ [resetNeutralHue, setNeutralHue], ); + const handleNeutralChromaChange = useCallback( + (value) => { + if (value === '') { + resetNeutralChroma(); + return; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return; + } + setNeutralChroma(parsed); + }, + [resetNeutralChroma, setNeutralChroma], + ); + + const handleNeutralContrastChange = useCallback( + (value) => { + if (value === '') { + resetNeutralContrast(); + return; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return; + } + setNeutralContrast(parsed); + }, + [resetNeutralContrast, setNeutralContrast], + ); + + const formatSliderValue = useCallback((value) => { + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return value; + } + return parsed.toFixed(2); + }, []); + const themeModeList = themeModes && themeModes.length ? themeModes : THEME_MODES; const themeModeIndex = themeModeList.indexOf(themeMode); const safeThemeModeIndex = themeModeIndex === -1 ? 0 : themeModeIndex; @@ -336,9 +384,9 @@ const Sidebar = ({ @@ -354,9 +402,38 @@ const Sidebar = ({ step="1" value={neutralHue} onChange={(event) => handleNeutralHueChange(event.target.value)} + onDoubleClick={resetNeutralHue} /> {neutralHue}° + + ) : null; diff --git a/frontend/src/sidebar/SidebarContext.js b/frontend/src/sidebar/SidebarContext.js index cb25e96..4ffabfc 100644 --- a/frontend/src/sidebar/SidebarContext.js +++ b/frontend/src/sidebar/SidebarContext.js @@ -12,19 +12,29 @@ const SIDEBAR_COLLAPSE_STORAGE_KEY = 'papercrate_sidebar_collapsed'; const SidebarContext = createContext(null); const THEME_STORAGE_KEY = 'papercrate_theme_settings'; -const DEFAULT_NEUTRAL_HUE = 180; +const DEFAULT_NEUTRAL_HUE = 145; +const DEFAULT_NEUTRAL_CHROMA = 0.15; +const DEFAULT_NEUTRAL_CONTRAST = 0.364; const DEFAULT_THEME_MODE = 'system'; const THEME_MODES = ['system', 'light', 'dark']; const loadInitialThemeSettings = () => { - const defaults = { neutralHue: DEFAULT_NEUTRAL_HUE, mode: DEFAULT_THEME_MODE }; + const defaults = { + neutralHue: DEFAULT_NEUTRAL_HUE, + neutralChroma: DEFAULT_NEUTRAL_CHROMA, + neutralContrast: DEFAULT_NEUTRAL_CONTRAST, + mode: DEFAULT_THEME_MODE, + }; if (typeof window === 'undefined') { if (typeof document !== 'undefined') { - const current = document.documentElement.style.getPropertyValue('--neutral-hue'); + const root = document.documentElement; + const current = root.style.getPropertyValue('--neutral-hue'); const parsed = Number.parseInt(current, 10); return { neutralHue: Number.isNaN(parsed) ? defaults.neutralHue : parsed, + neutralChroma: defaults.neutralChroma, + neutralContrast: defaults.neutralContrast, mode: defaults.mode, }; } @@ -32,13 +42,36 @@ const loadInitialThemeSettings = () => { } 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; + } + if (typeof window !== 'undefined' && window.getComputedStyle) { + 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 Number.isNaN(parsed) ? defaults.neutralHue : parsed; + return { + neutralHue: Number.isNaN(parsed) ? defaults.neutralHue : parsed, + neutralChroma: readNumberVar('--neutral-chroma', defaults.neutralChroma), + neutralContrast: readNumberVar('--neutral-contrast', defaults.neutralContrast), + }; }; - let neutralHueValue = loadFromRoot(); + 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); @@ -49,6 +82,14 @@ const loadInitialThemeSettings = () => { 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; @@ -70,7 +111,12 @@ const loadInitialThemeSettings = () => { } } - return { neutralHue: neutralHueValue, mode: modeValue }; + return { + neutralHue: neutralHueValue, + neutralChroma: neutralChromaValue, + neutralContrast: neutralContrastValue, + mode: modeValue, + }; }; const loadInitialCollapsedState = (defaultValue) => { @@ -95,6 +141,8 @@ 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(() => { @@ -104,6 +152,20 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => { document.documentElement.style.setProperty('--neutral-hue', `${neutralHue}deg`); }, [neutralHue]); + useEffect(() => { + if (typeof document === 'undefined') { + return; + } + document.documentElement.style.setProperty('--neutral-chroma', String(neutralChroma)); + }, [neutralChroma]); + + useEffect(() => { + if (typeof document === 'undefined') { + return; + } + document.documentElement.style.setProperty('--neutral-contrast', String(neutralContrast)); + }, [neutralContrast]); + useEffect(() => { if (typeof document === 'undefined') { return; @@ -121,14 +183,19 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => { return; } try { - const payload = JSON.stringify({ neutralHue, mode: themeMode }); + 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, themeMode]); + }, [neutralHue, neutralChroma, neutralContrast, themeMode]); const setNeutralHue = useCallback((value) => { setNeutralHueState((prev) => { @@ -144,6 +211,42 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => { }); }, []); + const setNeutralChroma = useCallback((value) => { + setNeutralChromaState((prev) => { + if (value === '' || value === null || typeof 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 || typeof 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); }, []); @@ -186,19 +289,33 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => { 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, diff --git a/frontend/src/styles/base/theme.css b/frontend/src/styles/base/theme.css index 7f558ef..7f648c6 100644 --- a/frontend/src/styles/base/theme.css +++ b/frontend/src/styles/base/theme.css @@ -1,26 +1,32 @@ :root { color-scheme: light dark; - --neutral-hue: 180deg; - --foreground-hue-offset: 10deg; - --accent-hue-offset: -20deg; - --dark-hue-offset: 30deg; - --dark-foreground-hue-offset: 10deg; - --surface-hue-shift: -4deg; + --neutral-hue: 145deg; + --surface-hue-shift: 6deg; + --foreground-hue-offset: 20deg; + --accent-hue-offset: 0deg; + --dark-hue-offset: 40deg; + --dark-foreground-hue-offset: 20deg; + --neutral-chroma: 0.15; + --neutral-contrast: 0.5; + --neutral-contrast-amount: calc(0.85 + var(--neutral-contrast) * 0.55); + --neutral-contrast-baseline: 1.125; + --neutral-contrast-delta: calc(var(--neutral-contrast-amount) - var(--neutral-contrast-baseline)); + --neutral-chroma-scale: calc(clamp(0.0001, var(--neutral-chroma), 1) / 0.1); /* --- Neutrals --- */ - --bg: oklch(0.97 0 calc(var(--neutral-hue) + var(--surface-hue-shift))); - --surface: oklch(0.985 0 calc(var(--neutral-hue) + var(--surface-hue-shift))); - --surface-subtle: oklch(0.96 0 calc(var(--neutral-hue) + var(--surface-hue-shift))); - --fg: oklch(0.32 0 calc(var(--neutral-hue) + var(--foreground-hue-offset))); - --muted: oklch(0.54 0 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --bg: oklch(calc(0.97 + var(--neutral-contrast-delta) * 0.12) calc(var(--neutral-chroma) * 0.001) calc(var(--neutral-hue) + var(--surface-hue-shift))); + --surface: oklch(calc(0.985 + var(--neutral-contrast-delta) * 0.1) calc(var(--neutral-chroma) * 0.001) calc(var(--neutral-hue) + var(--surface-hue-shift))); + --surface-subtle: oklch(calc(0.96 + var(--neutral-contrast-delta) * 0.16) calc(var(--neutral-chroma) * 0.002) calc(var(--neutral-hue) + var(--surface-hue-shift))); + --fg: oklch(calc(0.32 - var(--neutral-contrast-delta) * 0.18) calc(var(--neutral-chroma) * 0.003) calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --muted: oklch(calc(0.54 - var(--neutral-contrast-delta) * 0.05) calc(var(--neutral-chroma) * 0.005) calc(var(--neutral-hue) + var(--foreground-hue-offset))); --muted-subtle: color-mix(in oklch, var(--muted) 55%, var(--border)); - --sidebar-fg: oklch(0.56 0 calc(var(--neutral-hue) + var(--foreground-hue-offset))); - --border: oklch(0.92 0 calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --sidebar-fg: oklch(calc(0.56 + var(--neutral-contrast-delta) * 0.18) calc(var(--neutral-chroma) * 0.003) calc(var(--neutral-hue) + var(--foreground-hue-offset))); + --border: oklch(calc(0.92 + var(--neutral-contrast-delta) * 0.15) calc(var(--neutral-chroma) * 0.0015) calc(var(--neutral-hue) + var(--foreground-hue-offset))); /* --- Accent (primary) --- */ - --accent: oklch(0.61 0.16 calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset))); - --accent-hover: oklch(0.70 0.12 calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset))); + --accent: oklch(0.61 calc(0.16 * var(--neutral-chroma-scale)) calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset))); + --accent-hover: oklch(0.70 calc(0.12 * var(--neutral-chroma-scale)) calc(var(--neutral-hue) + var(--foreground-hue-offset) + var(--accent-hue-offset))); --on-accent: oklch(1 0 0); --folder-icon-back: color-mix(in oklch, var(--accent) 78%, black 8%); @@ -107,18 +113,17 @@ --dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset)); --dark-foreground-hue: calc(var(--neutral-hue) + var(--dark-foreground-hue-offset)); - - --bg: oklch(0.15 0.01 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); - --surface: oklch(0.19 0.012 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); - --surface-subtle: oklch(0.23 0.012 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); - --fg: oklch(0.89 0.015 var(--dark-foreground-hue)); - --muted: oklch(0.72 0.02 var(--dark-foreground-hue)); + --bg: oklch(calc(0.18 - var(--neutral-contrast-delta) * 0.015) calc(var(--neutral-chroma) * 0.1) calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --surface: oklch(calc(0.22 - var(--neutral-contrast-delta) * 0.012) calc(var(--neutral-chroma) * 0.12) calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --surface-subtle: oklch(calc(0.26 - var(--neutral-contrast-delta) * 0.01) calc(var(--neutral-chroma) * 0.12) calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --fg: oklch(calc(0.9 + var(--neutral-contrast-delta) * 0.02) calc(var(--neutral-chroma) * 0.04) var(--dark-foreground-hue)); + --muted: oklch(calc(0.72 + var(--neutral-contrast-delta) * 0.01) calc(var(--neutral-chroma) * 0.06) var(--dark-foreground-hue)); --muted-subtle: color-mix(in oklch, var(--muted) 45%, var(--border)); - --sidebar-fg: oklch(0.78 0.02 var(--dark-foreground-hue)); - --border: oklch(0.33 0.01 var(--dark-foreground-hue)); + --sidebar-fg: oklch(calc(0.78 + var(--neutral-contrast-delta) * 0.01) calc(var(--neutral-chroma) * 0.06) var(--dark-foreground-hue)); + --border: oklch(calc(0.33 - var(--neutral-contrast-delta) * 0.01) calc(var(--neutral-chroma) * 0.03) var(--dark-foreground-hue)); - --accent: oklch(0.75 0.16 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); - --accent-hover: oklch(0.82 0.13 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); + --accent: oklch(0.75 calc(0.16 * var(--neutral-chroma-scale)) calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); + --accent-hover: oklch(0.82 calc(0.13 * var(--neutral-chroma-scale)) calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); --on-accent: oklch(0.15 0.015 var(--dark-foreground-hue)); --folder-icon-back: color-mix(in oklch, var(--accent) 72%, black 12%); @@ -185,18 +190,17 @@ --dark-neutral-hue: calc(var(--neutral-hue) + var(--dark-hue-offset)); --dark-foreground-hue: calc(var(--neutral-hue) + var(--dark-foreground-hue-offset)); - - --bg: oklch(0.15 0 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); - --surface: oklch(0.19 0 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); - --surface-subtle: oklch(0.23 0 calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); - --fg: oklch(0.89 0 var(--dark-foreground-hue)); - --muted: oklch(0.72 0 var(--dark-foreground-hue)); + --bg: oklch(calc(0.5 - 0.35 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.1) calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --surface: oklch(calc(0.5 - 0.31 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.12) calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --surface-subtle: oklch(calc(0.5 - 0.27 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.12) calc(var(--dark-neutral-hue) + var(--surface-hue-shift))); + --fg: oklch(calc(0.5 + 0.39 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.04) var(--dark-foreground-hue)); + --muted: oklch(calc(0.5 + 0.22 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.06) var(--dark-foreground-hue)); --muted-subtle: color-mix(in oklch, var(--muted) 45%, var(--border)); - --sidebar-fg: oklch(0.78 0 var(--dark-foreground-hue)); - --border: oklch(0.33 0 var(--dark-foreground-hue)); + --sidebar-fg: oklch(calc(0.5 + 0.28 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.06) var(--dark-foreground-hue)); + --border: oklch(calc(0.5 - 0.17 * var(--neutral-contrast-amount)) calc(var(--neutral-chroma) * 0.03) var(--dark-foreground-hue)); - --accent: oklch(0.75 0.16 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); - --accent-hover: oklch(0.82 0.13 calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); + --accent: oklch(0.75 calc(0.16 * var(--neutral-chroma-scale)) calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); + --accent-hover: oklch(0.82 calc(0.13 * var(--neutral-chroma-scale)) calc(var(--dark-foreground-hue) + var(--accent-hue-offset))); --on-accent: oklch(0.15 0.015 var(--dark-foreground-hue)); --folder-icon-back: color-mix(in oklch, var(--accent) 72%, black 12%);