Each browser keeps its own theme, in a cookie (#69)

The theme was kept on the account, so every browser signed in as the
same person got the same one: no Glass on the phone with Dracula on the
desktop. It is now the ipx_theme cookie (<theme>.<mode>), written by
theme.ts, and read by the server to draw the page in it from the first
frame as before. /api/me no longer reports or takes a theme, and
set_theme is gone.

A browser with no cookie yet is sent the theme the account kept, and
takes it as its cookie on that first load, so nobody loses their choice
in the move. users.theme and theme_mode are only read now, for that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 20:27:20 +00:00
parent 7490a3a9ac
commit f57f824535
5 changed files with 62 additions and 75 deletions

View File

@@ -63,13 +63,9 @@ test('Settings picks a theme and, where it has both, light, dark or Auto', async
await page.locator('#stheme').selectOption('paper');
await expect(page.locator('#smode')).toBeHidden();
await expect.poll(bg).toBe('rgb(242, 238, 222)'); // #F2EEDE
// The save that says Classic: the ones before it may still be answering.
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH'
&& r.request().postDataJSON().theme === 'classic');
await page.locator('#stheme').selectOption('classic');
await expect(page.locator('#smode')).toBeHidden();
await saved; // kept on the account, not the browser
await page.reload();
await page.reload(); // kept in this browser's cookie
await expect.poll(root).toEqual(['classic', 'light']);
// The 2004 Mac app set its type in Lucida Grande.
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
@@ -83,46 +79,40 @@ test('Settings picks a theme and, where it has both, light, dark or Auto', async
await expect.poll(bg).toBe('rgb(46, 52, 64)'); // nord0
});
test('the theme is kept on the account, and follows it to another browser', async ({ page, browser }) => {
test('each browser keeps its own theme, in a cookie, not on the account', async ({ page, browser }) => {
// Glass on a phone, Dracula on a desktop, signed in as the same person (issue #69).
let patched = false;
page.on('request', r => { if (r.url().endsWith('/api/me') && r.method() === 'PATCH') patched = true; });
await page.locator('#prefs').click();
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.locator('#stheme').selectOption('flatremix');
expect((await saved).status()).toBe(204);
const saved2 = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.locator('#smode').selectOption('light');
await saved2;
const cookie = (await page.context().cookies()).find(c => c.name === 'ipx_theme');
expect(cookie?.value).toBe('flatremix.light');
expect(patched, 'nothing sent to the account').toBe(false);
// Another browser: nothing in its localStorage, and the page still arrives in the theme,
// written onto <html> by the server rather than set once the script has run.
// This browser: the server reads the cookie and draws the page in it from the first frame.
const res = await page.reload();
expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light');
// Another browser, the same account: not this one's theme.
const other = await browser.newContext();
const p2 = await other.newPage();
const res = await p2.goto(`/?token=${TOKEN}`);
expect(await res.text()).toContain('data-theme=flatremix data-choice=light data-mode=light');
expect(await p2.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
.toEqual(['flatremix', 'light']);
const res2 = await p2.goto(`/?token=${TOKEN}`);
expect(await res2.text()).not.toContain('data-theme=flatremix');
await other.close();
});
test('a theme this browser kept before themes were on the account goes up to it once', async ({ browser }) => {
// A new account, made by the proxy header on first sight, so it has no theme of its own yet.
test('a theme this browser kept in localStorage becomes its cookie once', async ({ browser }) => {
const who = `theme-${Date.now()}@example.com`;
const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
// From before light and dark: ipx.theme alone, 'light' meaning Modern, light.
await ctx.addInitScript(() => { localStorage.setItem('ipx.theme', 'light'); localStorage.removeItem('ipx.mode'); });
const page = await ctx.newPage();
const saved = page.waitForResponse(r => r.url().endsWith('/api/me') && r.request().method() === 'PATCH');
await page.goto('/');
expect(await page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]))
.toEqual(['modern', 'light']);
expect((await saved).request().postDataJSON()).toEqual({ theme: 'modern', mode: 'light' });
expect((await ctx.cookies()).find(c => c.name === 'ipx_theme')?.value).toBe('modern.light');
await ctx.close();
// Anywhere else now, it comes from the account.
const fresh = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': who } });
const p2 = await fresh.newPage();
const res = await p2.goto('/');
expect(await res.text()).toContain('data-theme=modern data-choice=light');
await fresh.close();
});
test('the admin page saves the global schedule', async ({ page }) => {