From c14b6df1dfb093fc991e9fc09bacc4b1db72e106 Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Wed, 9 Sep 2026 09:37:26 +0530 Subject: [PATCH] theme defaults in general settings save and apply correctly with auth enabled --- src/lib/components/AnimateIconsToggle.svelte | 9 ++-- .../components/ColoredActionsToggle.svelte | 10 +++-- src/lib/components/IndentGuidesToggle.svelte | 9 ++-- src/lib/components/ThemeSelector.svelte | 13 ++++++ src/lib/stores/theme.ts | 8 ++-- src/lib/utils/theme-preference.ts | 32 ++++++++++++++ src/routes/settings/general/GeneralTab.svelte | 34 +++++++++++++-- tests/theme-preference.test.ts | 42 +++++++++++++++++++ 8 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 src/lib/utils/theme-preference.ts create mode 100644 tests/theme-preference.test.ts diff --git a/src/lib/components/AnimateIconsToggle.svelte b/src/lib/components/AnimateIconsToggle.svelte index 4aa6e0e..faf8c5c 100644 --- a/src/lib/components/AnimateIconsToggle.svelte +++ b/src/lib/components/AnimateIconsToggle.svelte @@ -3,22 +3,25 @@ import { TogglePill } from '$lib/components/ui/toggle-pill'; import { themeStore } from '$lib/stores/theme'; import { authStore } from '$lib/stores/auth'; + import { resolveToggleValue, shouldSkipApply } from '$lib/utils/theme-preference'; import { toast } from 'svelte-sonner'; interface Props { userId?: number; // omit for global default (login page / auth-disabled) + // When set, the toggle reflects and saves this global default instead of the profile. + globalValue?: boolean; } - let { userId }: Props = $props(); + let { userId, globalValue }: Props = $props(); // Same "skip applying" rule as ThemeSelector: don't toggle the live document // when the admin is editing the global default while logged in (their own // per-user preference still drives their session). - const skipApply = $derived($authStore.loading ? true : ($authStore.authEnabled && !userId)); + const skipApply = $derived(shouldSkipApply($authStore.loading, $authStore.authEnabled, userId)); let checked = $state(true); $effect(() => { - checked = $themeStore.animateIcons; + checked = resolveToggleValue(globalValue, $themeStore.animateIcons); }); function onToggle(value: boolean) { diff --git a/src/lib/components/ColoredActionsToggle.svelte b/src/lib/components/ColoredActionsToggle.svelte index 55cb96c..8799a5b 100644 --- a/src/lib/components/ColoredActionsToggle.svelte +++ b/src/lib/components/ColoredActionsToggle.svelte @@ -3,19 +3,23 @@ import { TogglePill } from '$lib/components/ui/toggle-pill'; import { themeStore } from '$lib/stores/theme'; import { authStore } from '$lib/stores/auth'; + import { resolveToggleValue, shouldSkipApply } from '$lib/utils/theme-preference'; import { toast } from 'svelte-sonner'; interface Props { userId?: number; // omit for global default (login page / auth-disabled) + // When set, the toggle reflects and saves this global default (the General settings + // editor) instead of the current user's profile preference. Omit elsewhere. + globalValue?: boolean; } - let { userId }: Props = $props(); + let { userId, globalValue }: Props = $props(); - const skipApply = $derived($authStore.loading ? true : ($authStore.authEnabled && !userId)); + const skipApply = $derived(shouldSkipApply($authStore.loading, $authStore.authEnabled, userId)); let checked = $state(false); $effect(() => { - checked = $themeStore.coloredActionButtons; + checked = resolveToggleValue(globalValue, $themeStore.coloredActionButtons); }); function onToggle(value: boolean) { diff --git a/src/lib/components/IndentGuidesToggle.svelte b/src/lib/components/IndentGuidesToggle.svelte index e8c3094..8e2441a 100644 --- a/src/lib/components/IndentGuidesToggle.svelte +++ b/src/lib/components/IndentGuidesToggle.svelte @@ -3,22 +3,25 @@ import { TogglePill } from '$lib/components/ui/toggle-pill'; import { themeStore } from '$lib/stores/theme'; import { authStore } from '$lib/stores/auth'; + import { resolveToggleValue, shouldSkipApply } from '$lib/utils/theme-preference'; import { toast } from 'svelte-sonner'; interface Props { userId?: number; // omit for global default (login page / auth-disabled) + // When set, the toggle reflects and saves this global default instead of the profile. + globalValue?: boolean; } - let { userId }: Props = $props(); + let { userId, globalValue }: Props = $props(); // Same "skip applying" rule as ThemeSelector: don't touch the live editor when the // admin is editing the global default while logged in (their own per-user preference // still drives their session). - const skipApply = $derived($authStore.loading ? true : ($authStore.authEnabled && !userId)); + const skipApply = $derived(shouldSkipApply($authStore.loading, $authStore.authEnabled, userId)); let checked = $state(false); $effect(() => { - checked = $themeStore.editorIndentGuides; + checked = resolveToggleValue(globalValue, $themeStore.editorIndentGuides); }); function onToggle(value: boolean) { diff --git a/src/lib/components/ThemeSelector.svelte b/src/lib/components/ThemeSelector.svelte index 8b60e12..e97f382 100644 --- a/src/lib/components/ThemeSelector.svelte +++ b/src/lib/components/ThemeSelector.svelte @@ -50,6 +50,12 @@ let selectedTerminalFont = $state('system-mono'); let selectedEditorFont = $state('system-mono'); + // The global-scope values are fetched on mount; render waits on this so the selects + // don't briefly show the hardcoded defaults before the global settings arrive. Only + // the global editor (no userId) fetches; the user scope reads the store synchronously. + let valuesLoaded = $state(false); + const awaitingGlobal = $derived(!userId && !valuesLoaded); + onMount(async () => { // Load bundled monospace fonts for dropdown previews const fontsToLoad = monospaceFonts.filter(f => f.googleFont); @@ -96,6 +102,7 @@ // Use defaults on error } } + valuesLoaded = true; }); // Sync with themeStore changes only when editing user profile @@ -163,6 +170,11 @@
+ {#if awaitingGlobal} + +
+ {:else}
@@ -378,4 +390,5 @@
+ {/if}
diff --git a/src/lib/stores/theme.ts b/src/lib/stores/theme.ts index 983f7af..84fec0d 100644 --- a/src/lib/stores/theme.ts +++ b/src/lib/stores/theme.ts @@ -9,6 +9,7 @@ import { writable, get } from 'svelte/store'; import { getFont, getMonospaceFont, type FontMeta } from '$lib/themes'; +import { preferenceTarget } from '$lib/utils/theme-preference'; export type FontSize = 'xsmall' | 'small' | 'normal' | 'medium' | 'large' | 'xlarge'; export type ActionIconSize = 'small' | 'normal' | 'large' | 'xlarge'; @@ -161,12 +162,9 @@ function createThemeStore() { // Save to database (async, non-blocking) try { - const url = userId - ? `/api/profile/preferences` - : `/api/settings/general`; - + const { url, method } = preferenceTarget(userId); await fetch(url, { - method: userId ? 'PUT' : 'POST', + method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [key]: value }) }); diff --git a/src/lib/utils/theme-preference.ts b/src/lib/utils/theme-preference.ts new file mode 100644 index 0000000..0d97585 --- /dev/null +++ b/src/lib/utils/theme-preference.ts @@ -0,0 +1,32 @@ +/** + * Pure decisions behind the theme toggles. Kept out of theme.ts and the .svelte + * components (which touch localStorage/document/fetch) so they are unit-testable. + */ + +/** + * The value a toggle should display: the global default when one is provided (the + * General settings editor), otherwise the personal value from the theme store. + */ +export function resolveToggleValue(globalValue: boolean | undefined, storeValue: boolean): boolean { + return globalValue !== undefined ? globalValue : storeValue; +} + +/** + * Where setPreference persists: a per-user PUT to the profile when a userId is given, + * otherwise a POST to the global settings default (the default for new users). + */ +export function preferenceTarget(userId?: number): { url: string; method: 'PUT' | 'POST' } { + return userId + ? { url: '/api/profile/preferences', method: 'PUT' } + : { url: '/api/settings/general', method: 'POST' }; +} + +/** + * Whether to skip applying the change to the live document. Editing the global default + * (no userId) with auth on persists without changing the current admin's own view, whose + * per-user preference drives their session. Auth state still loading also skips. + */ +export function shouldSkipApply(authLoading: boolean, authEnabled: boolean, userId?: number): boolean { + if (authLoading) return true; + return authEnabled && !userId; +} diff --git a/src/routes/settings/general/GeneralTab.svelte b/src/routes/settings/general/GeneralTab.svelte index e789275..3658ab1 100644 --- a/src/routes/settings/general/GeneralTab.svelte +++ b/src/routes/settings/general/GeneralTab.svelte @@ -306,6 +306,15 @@ services: let semverIncludePrerelease = $state(false); let semverLoaded = $state(false); + // The global theme defaults (what a new user starts with). With auth on the theme + // toggles here edit these, not the admin's own profile. Rendering waits on + // globalThemeLoaded: until the fetch fills these, a toggle handed globalValue=undefined + // would fall back to the admin's personal store value and show it. + let globalColoredActions = $state(undefined); + let globalAnimateIcons = $state(undefined); + let globalIndentGuides = $state(undefined); + let globalThemeLoaded = $state(false); + onMount(async () => { try { const res = await fetch('/api/settings/semver'); @@ -318,6 +327,17 @@ services: } } catch { /* keep defaults */ } semverLoaded = true; + + try { + const res = await fetch('/api/settings/general'); + if (res.ok) { + const g = await res.json(); + globalColoredActions = !!g.coloredActionButtons; + globalAnimateIcons = g.animateIcons ?? true; + globalIndentGuides = !!g.editorIndentGuides; + } + } catch { /* toggles fall back to store when global value is unknown */ } + globalThemeLoaded = true; }); async function saveSemverConfig() { @@ -593,14 +613,20 @@ services:
- - - + + {#if !$authStore.authEnabled || globalThemeLoaded} + + + + {/if} {#if $authStore.authEnabled}
-

Personal theme preferences can be configured in your profile.

+

These are the defaults for new users - they don't change your own view. To customise how you see the app, use the theme settings in your profile.

{/if} diff --git a/tests/theme-preference.test.ts b/tests/theme-preference.test.ts new file mode 100644 index 0000000..446e57d --- /dev/null +++ b/tests/theme-preference.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'bun:test'; +import { resolveToggleValue, preferenceTarget, shouldSkipApply } from '../src/lib/utils/theme-preference'; + +describe('resolveToggleValue', () => { + it('shows the global default when one is provided (General editor)', () => { + expect(resolveToggleValue(true, false)).toBe(true); + expect(resolveToggleValue(false, true)).toBe(false); + }); + it('shows the store (personal) value when no global is provided', () => { + expect(resolveToggleValue(undefined, true)).toBe(true); + expect(resolveToggleValue(undefined, false)).toBe(false); + }); + it('a global value of false is respected, not treated as "unset"', () => { + // The guard is `!== undefined`, so `false` is a real global default, not a fallthrough. + expect(resolveToggleValue(false, true)).toBe(false); + }); +}); + +describe('preferenceTarget', () => { + it('saves to the profile (PUT) for a per-user preference', () => { + expect(preferenceTarget(42)).toEqual({ url: '/api/profile/preferences', method: 'PUT' }); + }); + it('saves to the global default (POST) when no user id is given', () => { + expect(preferenceTarget(undefined)).toEqual({ url: '/api/settings/general', method: 'POST' }); + }); +}); + +describe('shouldSkipApply', () => { + it('skips while auth state is still loading', () => { + expect(shouldSkipApply(true, false, undefined)).toBe(true); + expect(shouldSkipApply(true, true, 42)).toBe(true); + }); + it('skips a global edit when auth is on (admin keeps their own view)', () => { + expect(shouldSkipApply(false, true, undefined)).toBe(true); + }); + it('applies a per-user edit even with auth on', () => { + expect(shouldSkipApply(false, true, 42)).toBe(false); + }); + it('applies a global edit when auth is off (store is the global value)', () => { + expect(shouldSkipApply(false, false, undefined)).toBe(false); + }); +});