fix(redesign): prevent theme FOUC and sidebar jump on hard reload
- app.html: inline blocking script resolves the theme from localStorage (or prefers-color-scheme) and sets data-theme on <html> before first paint, eliminating the dark→light transition users saw when the light theme was selected. - +layout.svelte: hydrate sidebar collapsed state and expanded nav groups synchronously in their $state initializers instead of inside onMount, so the sidebar no longer snaps from expanded→collapsed and groups no longer slide open after mount. - +layout.svelte: keep the global provider-filter row rendered while providersCache.fetchedAt === 0, so the row doesn't pop in mid-paint and push the nav down once the cache resolves.
This commit is contained in:
@@ -5,6 +5,23 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Notify Bridge</title>
|
<title>Notify Bridge</title>
|
||||||
|
<script>
|
||||||
|
// Resolve theme before first paint to avoid dark→light FOUC on hard reload.
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem('theme');
|
||||||
|
var resolved =
|
||||||
|
saved === 'light' || saved === 'dark'
|
||||||
|
? saved
|
||||||
|
: window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
document.documentElement.setAttribute('data-theme', resolved);
|
||||||
|
} catch (_) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -38,6 +38,11 @@
|
|||||||
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
|
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
|
||||||
let _syncingFilter = false;
|
let _syncingFilter = false;
|
||||||
|
|
||||||
|
// Reserve the provider-filter row from first paint until the cache resolves.
|
||||||
|
// Without this, the row appears mid-paint and pushes nav items down on every
|
||||||
|
// hard reload — the most visible "jump" the user reported.
|
||||||
|
let showProviderFilter = $derived(allProviders.length >= 1 || providersCache.fetchedAt === 0);
|
||||||
|
|
||||||
// Sync filter value → store
|
// Sync filter value → store
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const v = providerFilterValue;
|
const v = providerFilterValue;
|
||||||
@@ -78,7 +83,24 @@
|
|||||||
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
|
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
let collapsed = $state(false);
|
// Read persisted UI state synchronously so first paint already matches the
|
||||||
|
// user's last session — otherwise the sidebar visibly snaps from expanded
|
||||||
|
// to collapsed (and groups slide open) right after mount.
|
||||||
|
function readPersistedCollapsed(): boolean {
|
||||||
|
if (typeof localStorage === 'undefined') return false;
|
||||||
|
return localStorage.getItem('sidebar_collapsed') === 'true';
|
||||||
|
}
|
||||||
|
function readPersistedExpandedGroups(): Record<string, boolean> {
|
||||||
|
if (typeof localStorage === 'undefined') return {};
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('nav_expanded');
|
||||||
|
return saved ? JSON.parse(saved) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let collapsed = $state(readPersistedCollapsed());
|
||||||
let isMac = $derived(typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent));
|
let isMac = $derived(typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent));
|
||||||
|
|
||||||
// Nav counts — computed reactively from caches + global provider filter
|
// Nav counts — computed reactively from caches + global provider filter
|
||||||
@@ -216,7 +238,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Track which groups are expanded (persisted in localStorage)
|
// Track which groups are expanded (persisted in localStorage)
|
||||||
let expandedGroups = $state<Record<string, boolean>>({});
|
let expandedGroups = $state<Record<string, boolean>>(readPersistedExpandedGroups());
|
||||||
|
|
||||||
function toggleGroup(key: string) {
|
function toggleGroup(key: string) {
|
||||||
expandedGroups = { ...expandedGroups, [key]: !expandedGroups[key] };
|
expandedGroups = { ...expandedGroups, [key]: !expandedGroups[key] };
|
||||||
@@ -262,13 +284,8 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
initTheme();
|
initTheme();
|
||||||
if (typeof localStorage !== 'undefined') {
|
// `collapsed` and `expandedGroups` are now hydrated synchronously in
|
||||||
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
// their $state initializers above to avoid a post-mount layout snap.
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem('nav_expanded');
|
|
||||||
if (saved) expandedGroups = JSON.parse(saved);
|
|
||||||
} catch (e) { console.warn('Failed to parse nav_expanded:', e); }
|
|
||||||
}
|
|
||||||
await loadUser();
|
await loadUser();
|
||||||
if (!auth.user && !isAuthPage) {
|
if (!auth.user && !isAuthPage) {
|
||||||
redirecting = true;
|
redirecting = true;
|
||||||
@@ -384,7 +401,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
Notify Bridge
|
Notify Bridge
|
||||||
</h1>
|
</h1>
|
||||||
<p class="brand-version font-mono">v0.5.2</p>
|
<p class="brand-version font-mono">v{__APP_VERSION__}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -398,8 +415,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Global provider filter -->
|
<!-- Global provider filter — kept rendered during the initial cache
|
||||||
{#if allProviders.length >= 1}
|
fetch (fetchedAt === 0) so the row doesn't pop in mid-paint and
|
||||||
|
push the nav down. Hides only once we confirm zero providers. -->
|
||||||
|
{#if showProviderFilter}
|
||||||
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
||||||
{#if collapsed}
|
{#if collapsed}
|
||||||
<button onclick={() => {
|
<button onclick={() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user