feat(fase-9): wire ThemeToggle into /settings + cross-device theme sync
Closes 9.1.6 (settings UI), 9.1.7 (reactivity), 9.1.8 (Playwright E2E). - /settings now has an "Appearance" section housing ThemeToggle. The onChange callback fires an UPDATE on public.users.theme so the choice follows the user across devices. The DB write is best-effort: a failed UPDATE does not roll back the immediate localStorage + <html data-theme> flip the toggle already performed. - Root +layout.svelte calls initTheme() once on mount so the JS-side stores hydrate from localStorage (the inline anti-FOUC script in app.html has already painted the correct theme by this point — this just rebuilds the JS state on top so reactive consumers see it). - (app)/+layout.svelte gains a one-shot effect that reads public.users.theme on first hydration and adopts the remote value when it differs from localStorage. Defers the PostgREST query through setTimeout(..., 0) per the Supabase auth-lock gotcha. - tests/e2e/theme.test.ts adds T-01 (toggle persists across reload), T-02 (system mode follows emulated prefers-color-scheme), and T-03 (refresh in dark mode never paints a light first frame, including on /logged-out which lives outside the (app) group and relies entirely on the inline app.html script). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { page } from '$app/stores';
|
||||
import { isAuthenticated, authLoading } from '$lib/stores/auth';
|
||||
import { isAuthenticated, authLoading, currentUser } from '$lib/stores/auth';
|
||||
import { login, isLoggingOut } from '$lib/auth';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import { setThemePreference, themePreference, type ThemePreference } from '$lib/theme';
|
||||
import DesktopSidebar from '$lib/components/layout/DesktopSidebar.svelte';
|
||||
import MobileTopBar from '$lib/components/layout/MobileTopBar.svelte';
|
||||
import BottomTabBar from '$lib/components/layout/BottomTabBar.svelte';
|
||||
@@ -33,6 +36,34 @@
|
||||
login();
|
||||
}
|
||||
});
|
||||
|
||||
// Fase 9.1: cross-device theme sync. When the user logs in, read
|
||||
// public.users.theme and adopt it if it differs from the local
|
||||
// preference. The anti-FOUC inline script in app.html and initTheme()
|
||||
// have already set the local default from localStorage, so this only
|
||||
// overrides when the server says otherwise. Defer the PostgREST query
|
||||
// out of the auth callback (gotcha already documented for collectives).
|
||||
let lastSyncedUserId: string | null = $state(null);
|
||||
$effect(() => {
|
||||
const userId = $currentUser?.id ?? null;
|
||||
if (!userId || userId === lastSyncedUserId) return;
|
||||
lastSyncedUserId = userId;
|
||||
setTimeout(async () => {
|
||||
const { data } = await getSupabase()
|
||||
.from('users')
|
||||
.select('theme')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
const remote = (data?.theme ?? null) as ThemePreference | null;
|
||||
if (
|
||||
remote &&
|
||||
(remote === 'light' || remote === 'dark' || remote === 'system') &&
|
||||
remote !== get(themePreference)
|
||||
) {
|
||||
setThemePreference(remote);
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $authLoading}
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
import { logout } from '$lib/auth';
|
||||
import Avatar from '$lib/components/Avatar.svelte';
|
||||
import ImageCropper from '$lib/components/ImageCropper.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import { languageTag, setLanguageTag } from '$lib/paraglide/runtime';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import type { ThemePreference } from '$lib/theme';
|
||||
import {
|
||||
PUBLIC_KEYCLOAK_URL,
|
||||
PUBLIC_KEYCLOAK_REALM
|
||||
@@ -132,6 +134,19 @@
|
||||
uploading = false;
|
||||
}
|
||||
|
||||
// Fase 9.1: persist the chosen theme to public.users.theme so it
|
||||
// follows the user across devices. ThemeToggle has already updated
|
||||
// localStorage + <html data-theme> synchronously — this is purely
|
||||
// the "save my preference server-side" step. Best-effort: a failed
|
||||
// UPDATE does not roll the UI choice back.
|
||||
async function persistTheme(next: ThemePreference) {
|
||||
if (!$currentUser) return;
|
||||
await getSupabase()
|
||||
.from('users')
|
||||
.update({ theme: next })
|
||||
.eq('id', $currentUser.id);
|
||||
}
|
||||
|
||||
async function switchLanguage(lang: 'en' | 'es') {
|
||||
language = lang;
|
||||
setLanguageTag(lang);
|
||||
@@ -232,6 +247,14 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Appearance (Fase 9.1) -->
|
||||
<section>
|
||||
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
{m.settings_appearance()}
|
||||
</p>
|
||||
<ThemeToggle onChange={persistTheme} />
|
||||
</section>
|
||||
|
||||
<!-- Language -->
|
||||
<section>
|
||||
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
|
||||
@@ -8,8 +8,15 @@
|
||||
import { userCollectives, currentCollective } from '$lib/stores/collective';
|
||||
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
|
||||
import { i18n } from '$lib/i18n';
|
||||
import { initTheme } from '$lib/theme';
|
||||
|
||||
onMount(() => {
|
||||
// Fase 9.1: hydrate the theme stores from localStorage and start
|
||||
// listening for OS prefers-color-scheme changes. The inline script
|
||||
// in app.html has already set <html data-theme> synchronously to
|
||||
// avoid FOUC — initTheme just builds the JS-side state on top.
|
||||
initTheme();
|
||||
|
||||
// Fase 6.1: register the PWA service worker. `@vite-pwa/sveltekit` does
|
||||
// not auto-register — the virtual module is a no-op unless called.
|
||||
void import('virtual:pwa-register').then(({ registerSW }) => {
|
||||
|
||||
Reference in New Issue
Block a user