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:
113
apps/web/tests/e2e/theme.test.ts
Normal file
113
apps/web/tests/e2e/theme.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user