This commit is contained in:
2025-11-11 22:38:40 +01:00
parent 1111784caa
commit b987bdacfe
3 changed files with 246 additions and 48 deletions
+82 -5
View File
@@ -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 = ({
<button
type="button"
className="icon-button"
onClick={handleNeutralHueReset}
aria-label="Reset neutral hue"
title="Reset neutral hue"
onClick={handleThemeAdjustmentsReset}
aria-label="Reset theme adjustments"
title="Reset theme adjustments"
>
<RestoreIcon size={16} />
</button>
@@ -354,9 +402,38 @@ const Sidebar = ({
step="1"
value={neutralHue}
onChange={(event) => handleNeutralHueChange(event.target.value)}
onDoubleClick={resetNeutralHue}
/>
<span className="menu__slider-value">{neutralHue}°</span>
</label>
<label className="menu__slider" htmlFor={neutralChromaInputId}>
<span className="menu__slider-label">Chroma</span>
<input
id={neutralChromaInputId}
type="range"
min="0"
max="1"
step="0.01"
value={neutralChroma}
onChange={(event) => handleNeutralChromaChange(event.target.value)}
onDoubleClick={resetNeutralChroma}
/>
<span className="menu__slider-value">{formatSliderValue(neutralChroma)}</span>
</label>
<label className="menu__slider" htmlFor={neutralContrastInputId}>
<span className="menu__slider-label">Contrast</span>
<input
id={neutralContrastInputId}
type="range"
min="0"
max="1"
step="0.01"
value={neutralContrast}
onChange={(event) => handleNeutralContrastChange(event.target.value)}
onDoubleClick={resetNeutralContrast}
/>
<span className="menu__slider-value">{formatSliderValue(neutralContrast)}</span>
</label>
</div>
)
: null;
+125 -8
View File
@@ -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,
+39 -35
View File
@@ -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%);