diff --git a/apps/web/src/routes/(app)/+layout.svelte b/apps/web/src/routes/(app)/+layout.svelte index ef33bed..c180e08 100644 --- a/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/web/src/routes/(app)/+layout.svelte @@ -1,8 +1,11 @@ {#if $authLoading} diff --git a/apps/web/src/routes/(app)/settings/+page.svelte b/apps/web/src/routes/(app)/settings/+page.svelte index 47f841e..b13b91f 100644 --- a/apps/web/src/routes/(app)/settings/+page.svelte +++ b/apps/web/src/routes/(app)/settings/+page.svelte @@ -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 + 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} + +
+

+ {m.settings_appearance()} +

+ +
+

diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 083d2be..19329f4 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -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 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 }) => { diff --git a/apps/web/tests/e2e/theme.test.ts b/apps/web/tests/e2e/theme.test.ts new file mode 100644 index 0000000..302e634 --- /dev/null +++ b/apps/web/tests/e2e/theme.test.ts @@ -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: . + 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(); + }); +});