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:
2026-05-18 01:15:47 +02:00
parent 5873c2fa9f
commit 9592879ad7
4 changed files with 175 additions and 1 deletions

View File

@@ -1,8 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { get } from 'svelte/store';
import { page } from '$app/stores'; 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 { 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 DesktopSidebar from '$lib/components/layout/DesktopSidebar.svelte';
import MobileTopBar from '$lib/components/layout/MobileTopBar.svelte'; import MobileTopBar from '$lib/components/layout/MobileTopBar.svelte';
import BottomTabBar from '$lib/components/layout/BottomTabBar.svelte'; import BottomTabBar from '$lib/components/layout/BottomTabBar.svelte';
@@ -33,6 +36,34 @@
login(); 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> </script>
{#if $authLoading} {#if $authLoading}

View File

@@ -5,8 +5,10 @@
import { logout } from '$lib/auth'; import { logout } from '$lib/auth';
import Avatar from '$lib/components/Avatar.svelte'; import Avatar from '$lib/components/Avatar.svelte';
import ImageCropper from '$lib/components/ImageCropper.svelte'; import ImageCropper from '$lib/components/ImageCropper.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import { languageTag, setLanguageTag } from '$lib/paraglide/runtime'; import { languageTag, setLanguageTag } from '$lib/paraglide/runtime';
import * as m from '$lib/paraglide/messages'; import * as m from '$lib/paraglide/messages';
import type { ThemePreference } from '$lib/theme';
import { import {
PUBLIC_KEYCLOAK_URL, PUBLIC_KEYCLOAK_URL,
PUBLIC_KEYCLOAK_REALM PUBLIC_KEYCLOAK_REALM
@@ -132,6 +134,19 @@
uploading = false; 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') { async function switchLanguage(lang: 'en' | 'es') {
language = lang; language = lang;
setLanguageTag(lang); setLanguageTag(lang);
@@ -232,6 +247,14 @@
{/if} {/if}
</section> </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 --> <!-- Language -->
<section> <section>
<p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary"> <p class="mb-3 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">

View File

@@ -8,8 +8,15 @@
import { userCollectives, currentCollective } from '$lib/stores/collective'; import { userCollectives, currentCollective } from '$lib/stores/collective';
import { ParaglideJS } from '@inlang/paraglide-sveltekit'; import { ParaglideJS } from '@inlang/paraglide-sveltekit';
import { i18n } from '$lib/i18n'; import { i18n } from '$lib/i18n';
import { initTheme } from '$lib/theme';
onMount(() => { 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 // Fase 6.1: register the PWA service worker. `@vite-pwa/sveltekit` does
// not auto-register — the virtual module is a no-op unless called. // not auto-register — the virtual module is a no-op unless called.
void import('virtual:pwa-register').then(({ registerSW }) => { void import('virtual:pwa-register').then(({ registerSW }) => {

View File

@@ -0,0 +1,113 @@
/**
* T-series: Dark mode (Fase 9.1).
*
* T-01 picking "Dark" on /settings persists across a hard reload.
* T-02 "System" follows the emulated prefers-color-scheme.
* T-03 a refresh in dark mode never renders a light first frame
* (anti-FOUC inline script in app.html). We also check /logged-out
* which lives outside the (app) group and would otherwise miss
* any layout-level theme hydration.
*/
import { test, expect } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
test.describe('Theme — three modes, persistence, anti-FOUC', () => {
test('T-01: picking Dark on /settings persists across reload', async ({ page }) => {
await loginAs(page, USERS.ana);
await page.goto('/settings');
// Reset to a known starting state.
await page.evaluate(() => localStorage.removeItem('theme'));
const darkButton = page
.getByRole('radiogroup', { name: /appearance|apariencia/i })
.getByRole('radio', { name: /dark|oscuro/i });
await expect(darkButton).toBeVisible({ timeout: 10_000 });
await darkButton.click();
// Immediate effect: <html data-theme="dark">.
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.theme)).toBe(
'dark'
);
await expect.poll(() => page.evaluate(() => localStorage.getItem('theme'))).toBe('dark');
// Hard reload → still dark, no flash to light.
await page.reload();
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.theme)).toBe(
'dark'
);
// Sanity: the toggle still reflects the choice.
await expect(
page
.getByRole('radiogroup', { name: /appearance|apariencia/i })
.getByRole('radio', { name: /dark|oscuro/i, checked: true })
).toBeVisible({ timeout: 10_000 });
});
test('T-02: "System" follows the emulated prefers-color-scheme', async ({ browser }) => {
// Two contexts — one emulates dark OS, the other light. Both pick
// "System" and we assert each resolves to the matching theme.
const darkCtx = await browser.newContext({ colorScheme: 'dark' });
const darkPage = await darkCtx.newPage();
await loginAs(darkPage, USERS.ana);
await darkPage.goto('/settings');
await darkPage.evaluate(() => localStorage.removeItem('theme'));
await darkPage
.getByRole('radiogroup', { name: /appearance|apariencia/i })
.getByRole('radio', { name: /system|sistema/i })
.click();
await expect
.poll(() => darkPage.evaluate(() => document.documentElement.dataset.theme))
.toBe('dark');
await darkCtx.close();
const lightCtx = await browser.newContext({ colorScheme: 'light' });
const lightPage = await lightCtx.newPage();
await loginAs(lightPage, USERS.borja);
await lightPage.goto('/settings');
await lightPage.evaluate(() => localStorage.removeItem('theme'));
await lightPage
.getByRole('radiogroup', { name: /appearance|apariencia/i })
.getByRole('radio', { name: /system|sistema/i })
.click();
await expect
.poll(() => lightPage.evaluate(() => document.documentElement.dataset.theme))
.toBe('light');
await lightCtx.close();
});
test('T-03: refresh in dark mode never paints a light first frame (anti-FOUC)', async ({
browser
}) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await loginAs(page, USERS.ana);
// Lock the preference into localStorage so the inline app.html script
// has to do the work on the next navigation.
await page.evaluate(() => localStorage.setItem('theme', 'dark'));
// Hard reload of /settings (an authed page).
await page.goto('/settings');
const themeAttr = await page.evaluate(() =>
document.documentElement.getAttribute('data-theme')
);
expect(themeAttr).toBe('dark');
// /logged-out lives outside the (app) group — no layout hydration
// helps it. The inline script in app.html must still kick in.
await page.goto('/logged-out');
const themeAttrLoggedOut = await page.evaluate(() =>
document.documentElement.getAttribute('data-theme')
);
expect(themeAttrLoggedOut).toBe('dark');
// Also assert there is no visible light flash by reading the actual
// computed background colour. The dark token resolves to rgb(2, 6, 23).
const bg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
expect(bg.replace(/\s/g, '')).toBe('rgb(2,6,23)');
await ctx.close();
});
});