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);
+ });
+});