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%);