diff --git a/webapp/e2e/studio-nocturne.spec.ts b/webapp/e2e/studio-nocturne.spec.ts index 85d35eaf..e17da04d 100644 --- a/webapp/e2e/studio-nocturne.spec.ts +++ b/webapp/e2e/studio-nocturne.spec.ts @@ -1462,12 +1462,11 @@ test("renders the studio shell and persists appearance preferences", async ({ page.getByRole("heading", { name: "Account", exact: true }), ).toBeVisible(); const preferences = page.locator(".settings-panel").first(); - const theme = preferences.getByLabel("Theme"); - await theme.selectOption({ index: 1 }); - const selectedTheme = await theme.inputValue(); + const theme = preferences.getByRole("radiogroup", { name: "Theme" }); + await theme.getByRole("radio", { name: "Midnight light" }).check(); await expect .poll(() => page.evaluate(() => localStorage.getItem("waveflow.theme"))) - .toBe(selectedTheme); + .toBe("midnight-light"); const language = preferences.getByLabel("Language"); await language.selectOption("fr"); diff --git a/webapp/e2e/themes.spec.ts b/webapp/e2e/themes.spec.ts new file mode 100644 index 00000000..0cbb6fd0 --- /dev/null +++ b/webapp/e2e/themes.spec.ts @@ -0,0 +1,491 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, type Page, test } from "@playwright/test"; + +import { themeSurfaces } from "../src/styles/design-tokens/apply"; +import { THEME_PRESETS } from "../src/styles/design-tokens/themes"; + +/** + * The theme picker and the fourteen presets it offers, against a mock + * server: the cards, the keyboard, following the device's scheme, and the + * colour contrast of every preset on the screens that spend its colours most. + * + * The roster is read from the catalogue itself rather than copied here, so a + * preset added to `THEME_PRESETS` is audited without anyone remembering to. + */ + +const session = { + access_token: "e2e-access-token", + user: { id: "user-1", username: "listener", role: "admin" }, + device_id: "device-1", +}; + +const library = { + id: "library-1", + name: "Ma musique", + visibility: "private", + role: "owner", + last_scan_started_at: null, + last_scan_completed_at: null, + accepts_uploads: false, + accepts_canvas: false, + follow_links: false, +}; + +const albums = [ + { + id: "album-1", + library_id: "library-1", + title: "Post", + artist: "Björk", + artist_id: "artist-1", + artwork_hash: null, + year: 1995, + starred_at: null, + user_rating: null, + }, + { + id: "album-2", + library_id: "library-1", + title: "Vespertine", + artist: "Björk", + artist_id: "artist-1", + artwork_hash: null, + year: 2001, + starred_at: null, + user_rating: null, + }, +]; + +const song = (index: number, title: string, rating: number, starred: boolean) => ({ + id: `song-${index}`, + library_id: "library-1", + album_id: "album-2", + title, + album: "Vespertine", + artist: "Björk", + artist_id: "artist-1", + artwork_hash: null, + duration_ms: 300_000, + track: index, + disc: 1, + starred_at: starred ? 1 : null, + user_rating: rating, + codec: "FLAC", + suffix: "flac", + size: 27_983_872, + bitrate: 957, + sample_rate: 44_100, + channels: 2, + bit_depth: 16, +}); + +/** Starred and rated, so the song table draws its accented marks too. */ +const songs = [ + song(1, "Hidden Place", 5, true), + song(2, "Cocoon", 0, false), + song(3, "Undo", 2, false), +]; + +/** Thirty seconds of silence, as a WAV the browser can load. */ +function silence(): Buffer { + const samples = 8000 * 30; + const header = Buffer.alloc(44); + header.write("RIFF", 0); + header.writeUInt32LE(36 + samples, 4); + header.write("WAVE", 8); + header.write("fmt ", 12); + header.writeUInt32LE(16, 16); + header.writeUInt16LE(1, 20); + header.writeUInt16LE(1, 22); + header.writeUInt32LE(8000, 24); + header.writeUInt32LE(8000, 28); + header.writeUInt16LE(1, 32); + header.writeUInt16LE(8, 34); + header.write("data", 36); + header.writeUInt32LE(samples, 40); + return Buffer.concat([header, Buffer.alloc(samples, 128)]); +} + +async function mockApi(page: Page) { + await page.context().addCookies([ + { + name: "waveflow-csrf", + value: "e2e-csrf", + url: test.info().project.use.baseURL ?? "http://127.0.0.1:4173", + }, + ]); + await page.route("**/api/v2/**", async (route) => { + const path = new URL(route.request().url()).pathname; + const answers: Record = { + "/api/v2/web/auth/refresh": session, + "/api/v2/libraries": [library], + "/api/v2/albums": albums, + "/api/v2/albums/album-2": { ...albums[1], songs }, + // A queue the server remembers, so the play bar is drawn on every + // screen and measured with it. + "/api/v2/queue": { + current: "song-1", + position_ms: 0, + changed_by: null, + updated_at: 1, + songs, + }, + "/api/v2/playlists": [], + "/api/v2/smart-playlists": [], + "/api/v2/podcasts": { enabled: false, can_manage: false, channels: [] }, + "/api/v2/radio-stations": [], + "/api/v2/now-playing": [], + "/api/v2/bookmarks": [], + "/api/v2/api-tokens": [], + "/api/v2/subsonic-credential": null, + "/api/v2/shares": [], + }; + if (path in answers) { + await route.fulfill({ json: answers[path] }); + return; + } + // The remembered track loads like a real one, so the play bar is drawn + // in its ordinary state and not in its playback error. + const ticket = path.match(/^\/api\/v2\/tracks\/([^/]+)\/stream-ticket$/); + if (ticket) { + await route.fulfill({ + json: { + url: `/api/v2/stream/ticket-${ticket[1]}`, + expires_at: Date.now() + 3_600_000, + }, + }); + return; + } + if (path.startsWith("/api/v2/stream/")) { + await route.fulfill({ + body: silence(), + headers: { "content-type": "audio/wav" }, + }); + return; + } + if (path.startsWith("/api/v2/tracks/")) { + await route.fulfill({ json: songs[0] }); + return; + } + await route.fulfill({ status: 404, json: { error: "not found" } }); + }); +} + +test.beforeEach(async ({ page }) => { + await mockApi(page); +}); + +/** Lets every finite animation land, so nothing is measured mid-fade. */ +async function settle(page: Page) { + await page.evaluate(() => + Promise.all( + document + .getAnimations() + .filter( + (animation) => + animation.effect?.getComputedTiming().endTime !== Infinity, + ) + .map((animation) => animation.finished.catch(() => undefined)), + ), + ); +} + +function picker(page: Page) { + return page.getByRole("radiogroup", { name: "Theme" }); +} + +test("draws a card per preset under a Light and a Dark heading", async ({ + page, +}) => { + await page.goto("/settings"); + const themes = picker(page); + await expect(themes).toBeVisible(); + await expect(themes.getByRole("radio")).toHaveCount(14); + await expect( + themes.getByRole("group", { name: "Light" }).getByRole("radio"), + ).toHaveCount(6); + await expect( + themes.getByRole("group", { name: "Dark" }).getByRole("radio"), + ).toHaveCount(8); + + // A first visit keeps the default, and its card is the checked one, with + // the accent's edge and the check that says so. + await expect(page.locator("html")).toHaveAttribute("data-theme", "default-dark"); + const current = themes.getByRole("radio", { checked: true }); + await expect(current).toHaveCount(1); + await expect(current).toHaveAccessibleName("Emerald dark"); + const card = page.locator('.theme-card[data-theme-id="default-dark"]'); + await expect(card.locator(".theme-card-check")).toBeVisible(); + await expect(page.locator(".theme-card-check")).toHaveCount(1); + + // Each miniature is painted in its own preset's colours, not the page's: + // the Midnight card's sidebar is Midnight's surface while Emerald is on. + const midnightSidebar = await page + .locator('.theme-card[data-theme-id="midnight"] .theme-card-sidebar') + .evaluate((element) => getComputedStyle(element).backgroundColor); + const emeraldSidebar = await page + .locator('.theme-card[data-theme-id="default-dark"] .theme-card-sidebar') + .evaluate((element) => getComputedStyle(element).backgroundColor); + expect(midnightSidebar).not.toBe(emeraldSidebar); + + // Picking a card applies and stores it. + await themes.getByRole("radio", { name: "Midnight", exact: true }).check(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "midnight"); + await expect( + themes.getByRole("radio", { name: "Midnight", exact: true }), + ).toBeChecked(); + await expect + .poll(() => page.evaluate(() => localStorage.getItem("waveflow.theme"))) + .toBe("midnight"); + await expect( + page.locator('.theme-card[data-theme-id="midnight"] .theme-card-check'), + ).toBeVisible(); + await expect(page.locator(".theme-card-check")).toHaveCount(1); +}); + +test("walks the cards with the arrow keys, selecting as it goes", async ({ + page, +}, testInfo) => { + test.skip(testInfo.project.name === "mobile", "a keyboard test"); + await page.goto("/settings"); + const themes = picker(page); + // One stop in the tab order, on the checked card: tabbing from the box + // above lands on Emerald dark and not on the first card. + await page.getByRole("checkbox", { name: "Follow the system" }).focus(); + await page.keyboard.press("Tab"); + const emeraldDark = themes.getByRole("radio", { name: "Emerald dark" }); + await expect(emeraldDark).toBeFocused(); + await expect(page.locator(".theme-card:focus-within")).toHaveCSS( + "outline-style", + "solid", + ); + + await page.keyboard.press("ArrowRight"); + const oled = themes.getByRole("radio", { name: "OLED black" }); + await expect(oled).toBeFocused(); + await expect(oled).toBeChecked(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "oled"); + + // Back across the boundary between the rows, into the light one. + await page.keyboard.press("ArrowLeft"); + await page.keyboard.press("ArrowLeft"); + const oceanLight = themes.getByRole("radio", { name: "Ocean light" }); + await expect(oceanLight).toBeChecked(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "ocean-light"); + + // And the tab order still holds one stop: out and back lands on it. + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("Tab"); + await expect(oceanLight).toBeFocused(); +}); + +test("follows the device between a preset and its pair, live", async ({ + page, +}) => { + await page.emulateMedia({ colorScheme: "light" }); + await page.addInitScript(() => { + if (!sessionStorage.getItem("seeded")) { + localStorage.setItem("waveflow.theme", "midnight"); + sessionStorage.setItem("seeded", "1"); + } + }); + await page.goto("/settings"); + const themes = picker(page); + const html = page.locator("html"); + await expect(html).toHaveAttribute("data-theme", "midnight"); + + const follow = page.getByRole("checkbox", { name: "Follow the system" }); + await expect(follow).not.toBeChecked(); + await follow.check(); + // The device is light: Midnight's light half, and its card is checked. + await expect(html).toHaveAttribute("data-theme", "midnight-light"); + await expect( + themes.getByRole("radio", { name: "Midnight light" }), + ).toBeChecked(); + await expect(page.getByText("Your device is in light mode")).toBeVisible(); + + // The device turns dark, and the page goes with it without a reload. + await page.emulateMedia({ colorScheme: "dark" }); + await expect(html).toHaveAttribute("data-theme", "midnight"); + + // Picking a card picks its family: Sunset light, at night, is Sunset. + await themes.getByRole("radio", { name: "Sunset light" }).click(); + await expect(html).toHaveAttribute("data-theme", "sunset"); + await expect(themes.getByRole("radio", { name: "Sunset", exact: true })).toBeChecked(); + + // Kept per browser, beside the theme, and read back on the next visit. + await expect + .poll(() => + page.evaluate(() => localStorage.getItem("waveflow.themeFollowSystem")), + ) + .toBe("true"); + await page.emulateMedia({ colorScheme: "light" }); + await page.reload(); + await expect(html).toHaveAttribute("data-theme", "sunset-light"); + await expect(follow).toBeChecked(); + + // A one-off has no light half: Emerald stands in, and the page says so. + await themes.getByRole("radio", { name: "OLED black" }).click(); + await expect(html).toHaveAttribute("data-theme", "default"); + await expect( + page.getByText("OLED black has no light version, so Emerald light stands in"), + ).toBeVisible(); + await page.emulateMedia({ colorScheme: "dark" }); + await expect(html).toHaveAttribute("data-theme", "oled"); + + // Turning it off keeps what is on screen rather than jumping. + await page.emulateMedia({ colorScheme: "light" }); + await expect(html).toHaveAttribute("data-theme", "default"); + await follow.uncheck(); + await expect(html).toHaveAttribute("data-theme", "default"); + await page.emulateMedia({ colorScheme: "dark" }); + await expect(html).toHaveAttribute("data-theme", "default"); + await expect + .poll(() => page.evaluate(() => localStorage.getItem("waveflow.theme"))) + .toBe("default"); +}); + +/** + * What a colour value computes to in this page, by painting it on a probe: + * the expected value goes through the same engine as the page's own, so the + * two compare as computed colours and not as spellings. + */ +async function painted(page: Page, value: string): Promise { + return page.evaluate((colour) => { + const probe = document.createElement("div"); + probe.style.backgroundColor = colour; + document.body.append(probe); + const computed = getComputedStyle(probe).backgroundColor; + probe.remove(); + return computed; + }, value); +} + +function backgroundOf(page: Page, selector: string): Promise { + return page + .locator(selector) + .first() + .evaluate((element) => getComputedStyle(element).backgroundColor); +} + +test("paints the page in each dark preset's own surfaces", async ({ + page, +}, testInfo) => { + await page.goto("/settings"); + const themes = picker(page); + await expect(themes).toBeVisible(); + const desktop = testInfo.project.name === "desktop"; + const grounds = new Set(); + for (const id of ["midnight", "lavender", "ocean", "oled"]) { + const preset = THEME_PRESETS.find((candidate) => candidate.id === id); + if (!preset) throw new Error(`no preset ${id}`); + await page.locator(`.theme-card[data-theme-id="${id}"]`).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", id); + const { ground, surface } = themeSurfaces(preset); + + // The page's ground is the preset's ambient, and OLED's is black. + const body = await backgroundOf(page, "body"); + expect(body, `${id}: the page`).toBe(await painted(page, ground)); + grounds.add(body); + // The play bar and the rail are its panel, as translucent as they are. + expect(await backgroundOf(page, ".player"), `${id}: the play bar`).toBe( + await painted(page, `color-mix(in srgb, ${surface} 92%, transparent)`), + ); + if (desktop) { + expect(await backgroundOf(page, ".sidebar"), `${id}: the rail`).toBe( + await painted(page, `color-mix(in srgb, ${surface} 82%, transparent)`), + ); + } + // And the card that chose it shows what the page now paints. + const card = page.locator(`.theme-card[data-theme-id="${id}"]`); + expect( + await backgroundOf(page, `.theme-card[data-theme-id="${id}"] .theme-card-preview`), + `${id}: the card's ground`, + ).toBe(body); + expect( + await card + .locator(".theme-card-sidebar") + .evaluate((element) => getComputedStyle(element).backgroundColor), + `${id}: the card's panel`, + ).toBe(await painted(page, "var(--fe-surface)")); + } + expect(grounds.size).toBe(4); + expect(grounds.has("rgb(0, 0, 0)")).toBe(true); +}); + +test("reflows the cards at any width without scrolling sideways", async ({ + page, +}) => { + await page.goto("/settings"); + await expect(picker(page)).toBeVisible(); + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + // Every card inside the viewport's width, and at least two abreast. + const boxes = await page.locator(".theme-card").evaluateAll((cards) => + cards.map((card) => card.getBoundingClientRect().toJSON()), + ); + const width = await page.evaluate(() => document.documentElement.clientWidth); + for (const box of boxes) expect(box.right).toBeLessThanOrEqual(width); + const firstRow = boxes.filter((box) => box.top === boxes[0]?.top); + expect(firstRow.length).toBeGreaterThanOrEqual(2); +}); + +test("drops the cards' motion when the device asks for less", async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/settings"); + const card = page.locator(".theme-card").first(); + await expect(card).toBeVisible(); + const durations = await card.evaluate((element) => + getComputedStyle(element).transitionDuration.split(",").map((value) => value.trim()), + ); + expect(durations.every((value) => value === "0s")).toBe(true); +}); + +/** + * The screens each preset is audited on: the albums grid (the landing page), + * Settings (the picker itself, a form, the toggles), and an album's song + * table with a starred, rated and playing row. The play bar is on all three. + */ +const SCREENS = [ + { + path: "/", + ready: (page: Page) => + expect(page.getByRole("heading", { name: "Albums" })).toBeVisible(), + }, + { + path: "/settings", + ready: (page: Page) => expect(picker(page)).toBeVisible(), + }, + { + path: "/albums/album-2", + ready: (page: Page) => + expect(page.getByRole("row", { name: /Cocoon/ })).toBeVisible(), + }, +]; + +for (const preset of THEME_PRESETS) { + test(`holds colour contrast in ${preset.id}`, async ({ page }) => { + await page.addInitScript((id) => { + localStorage.setItem("waveflow.theme", id); + }, preset.id); + for (const screen of SCREENS) { + await page.goto(screen.path); + await expect(page.locator("html")).toHaveAttribute("data-theme", preset.id); + await screen.ready(page); + await expect(page.locator(".player")).toBeVisible(); + await settle(page); + const results = await new AxeBuilder({ page }) + .withRules(["color-contrast"]) + .analyze(); + expect( + results.violations.flatMap((violation) => + violation.nodes.map((node) => `${screen.path} ${node.target.join(" ")}: ${node.failureSummary}`), + ), + ).toEqual([]); + } + }); +} diff --git a/webapp/src/components/theme-picker.tsx b/webapp/src/components/theme-picker.tsx new file mode 100644 index 00000000..8d1fce95 --- /dev/null +++ b/webapp/src/components/theme-picker.tsx @@ -0,0 +1,214 @@ +import { type CSSProperties, useId } from "react"; +import { type TranslationKey, useI18n } from "../state/i18n"; +import { useThemePreference } from "../state/preferences"; +import { + findTheme, + THEME_PRESETS, + type ThemeMode, + type ThemePreset, + themeSurfaces, +} from "../styles/design-tokens"; + +const MODES: ReadonlyArray<{ mode: ThemeMode; label: TranslationKey }> = [ + { mode: "light", label: "preferences.themeLight" }, + { mode: "dark", label: "preferences.themeDark" }, +]; + +/** + * The cards in the order they are drawn, and so walked by the arrow keys: the + * light row, then the dark one, each in the catalogue's order. + */ +const ORDER: ReadonlyArray = MODES.flatMap(({ mode }) => + THEME_PRESETS.filter((preset) => preset.mode === mode), +); + +/** + * One preset's miniature, as custom properties the card's stylesheet reads. + * + * Drawn from the preset's own tokens — its ground, panel and raised surface, + * and its accent at the stops the material spends — so every card shows its + * own theme whichever one is applied, the way the desktop's picker does. + */ +function previewStyle(preset: ThemePreset): CSSProperties { + const surfaces = themeSurfaces(preset); + const dark = preset.mode === "dark"; + const ink = dark ? "#f3f1ec" : "#1b1a17"; + return { + "--preview-ground": surfaces.ground, + // A dark preset's panel is the page's own `--fe-surface`, already a step + // above the ground. A light preset's surface is often its ground itself, + // so the miniature shades it to keep the sidebar visible. + "--preview-sidebar": dark + ? surfaces.surface + : `color-mix(in srgb, ${surfaces.surface} 94%, ${ink})`, + "--preview-raised": surfaces.raised, + "--preview-ink": ink, + "--preview-accent": dark ? preset.accent[400] : preset.accent[600], + "--preview-accent-deep": dark ? preset.accent[600] : preset.accent[800], + } as CSSProperties; +} + +/** + * The theme picker: a card per preset, under a Light and a Dark heading, and + * the box that hands the choice between a preset and its pair to the device. + * + * **Native radios under the cards.** Each card is the label of a real + * ``, all fourteen under one name, so the browser does + * what a radio group must and a hand-rolled one gets wrong somewhere: one + * stop in the tab order (the checked card), arrow keys that move and select + * across both rows and wrap, Space on a focused card, and the checked state + * read out. The cards draw the state; the inputs carry it. + * + * **The checked card is the one on screen.** While following the device, + * picking Midnight light at night checks Midnight: the pick chose the + * family, and the device chose its member. + */ +export function ThemePicker() { + const { t } = useI18n(); + const preferences = useThemePreference(); + const headingId = useId(); + const name = (preset: ThemePreset) => t(preset.labelKey as TranslationKey); + + const chosen = findTheme(preferences.chosenThemeId); + const shown = findTheme(preferences.themeId); + // A one-off with no pair (OLED, Neon) cannot follow the device across: + // Emerald stands in, and the page says so instead of seeming to ignore the + // pick. + const standIn = + preferences.followSystem && + shown.id !== chosen.id && + shown.id !== chosen.pair; + + return ( +
+

+ {t("preferences.theme")} +

+ {preferences.systemScheme !== null ? ( +
+ +

+ {!preferences.followSystem + ? t("preferences.followSystemDetail") + : preferences.systemScheme === "dark" + ? t("preferences.followingDark") + : t("preferences.followingLight")} +

+ {standIn ? ( +

+ {t( + shown.mode === "light" + ? "preferences.standInLight" + : "preferences.standInDark", + { theme: name(chosen), fallback: name(shown) }, + )} +

+ ) : null} +
+ ) : null} +
+ {MODES.map(({ mode, label }) => { + const groupHeadingId = `${headingId}-${mode}`; + return ( +
+
+ {t(label)} +
+
+ {ORDER.filter((preset) => preset.mode === mode).map( + (preset) => { + const checked = preset.id === preferences.themeId; + const nameId = `${headingId}-${preset.id}-name`; + const modeId = `${headingId}-${preset.id}-mode`; + return ( + + ); + }, + )} +
+
+ ); + })} +
+
+ ); +} diff --git a/webapp/src/lib/media-query.test.ts b/webapp/src/lib/media-query.test.ts new file mode 100644 index 00000000..3f6f62e4 --- /dev/null +++ b/webapp/src/lib/media-query.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; + +import { onMediaChange } from "./media-query"; + +describe("onMediaChange", () => { + it("listens through addEventListener where the browser has it", () => { + const query = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + } as unknown as MediaQueryList; + const listener = () => {}; + const stop = onMediaChange(query, listener); + expect(query.addEventListener).toHaveBeenCalledWith("change", listener); + expect(query.addListener).not.toHaveBeenCalled(); + stop(); + expect(query.removeEventListener).toHaveBeenCalledWith("change", listener); + }); + + // Safari before 14: only the deprecated pair exists, and reaching for the + // missing one would throw. + it("falls back to addListener on an older MediaQueryList", () => { + const query = { + addListener: vi.fn(), + removeListener: vi.fn(), + } as unknown as MediaQueryList; + const listener = () => {}; + const stop = onMediaChange(query, listener); + expect(query.addListener).toHaveBeenCalledWith(listener); + stop(); + expect(query.removeListener).toHaveBeenCalledWith(listener); + }); + + it("does nothing, and throws nothing, where neither exists", () => { + const query = {} as MediaQueryList; + expect(() => onMediaChange(query, () => {})()).not.toThrow(); + }); +}); diff --git a/webapp/src/lib/media-query.ts b/webapp/src/lib/media-query.ts new file mode 100644 index 00000000..fc5e4ed5 --- /dev/null +++ b/webapp/src/lib/media-query.ts @@ -0,0 +1,23 @@ +/** + * Calls `listener` whenever `query` stops or starts matching, and answers the + * function that stops it. + * + * `addEventListener` on a `MediaQueryList` arrived in Safari 14; before it, + * only the deprecated `addListener` exists, and calling the missing method + * throws inside an effect, which takes the whole page down with it. The old + * spelling is used where the new one is absent. + */ +export function onMediaChange( + query: MediaQueryList, + listener: () => void, +): () => void { + if (typeof query.addEventListener === "function") { + query.addEventListener("change", listener); + return () => query.removeEventListener("change", listener); + } + if (typeof query.addListener === "function") { + query.addListener(listener); + return () => query.removeListener(listener); + } + return () => {}; +} diff --git a/webapp/src/pages/settings-page.tsx b/webapp/src/pages/settings-page.tsx index 2736fa18..d6e953a4 100644 --- a/webapp/src/pages/settings-page.tsx +++ b/webapp/src/pages/settings-page.tsx @@ -9,8 +9,9 @@ import { } from "../api"; import { ownApiTokensQuery, ownSubsonicCredentialQuery } from "../api/queries"; import { Loading, PageHeader, useReload } from "../components/page-shell"; +import { ThemePicker } from "../components/theme-picker"; import { LanguagePicker, type Locale, useI18n } from "../state/i18n"; -import { ReplayGainPicker, ThemePicker } from "../state/preferences"; +import { ReplayGainPicker } from "../state/preferences"; /** * The Account section of Settings, the clients allowed to read the library, diff --git a/webapp/src/state/i18n.tsx b/webapp/src/state/i18n.tsx index c2fcd7c2..bda1f976 100644 --- a/webapp/src/state/i18n.tsx +++ b/webapp/src/state/i18n.tsx @@ -31,6 +31,19 @@ const en = { "nav.moreClose": "Close", "preferences.theme": "Theme", "preferences.language": "Language", + "preferences.themeLight": "Light", + "preferences.themeDark": "Dark", + "preferences.followSystem": "Follow the system", + "preferences.followSystemDetail": + "Switch between a theme’s light and dark versions when your device does.", + "preferences.followingLight": + "Your device is in light mode, so the light version is shown. Picking a theme picks both of its versions.", + "preferences.followingDark": + "Your device is in dark mode, so the dark version is shown. Picking a theme picks both of its versions.", + "preferences.standInLight": + "{theme} has no light version, so {fallback} stands in while your device is in light mode.", + "preferences.standInDark": + "{theme} has no dark version, so {fallback} stands in while your device is in dark mode.", "replayGain.label": "ReplayGain", "replayGain.off": "Off", "replayGain.track": "Track gain", @@ -1170,6 +1183,19 @@ const fr: Record = { "nav.moreClose": "Fermer", "preferences.theme": "Thème", "preferences.language": "Langue", + "preferences.themeLight": "Clair", + "preferences.themeDark": "Sombre", + "preferences.followSystem": "Suivre le système", + "preferences.followSystemDetail": + "Passer de la version claire d’un thème à sa version sombre en même temps que votre appareil.", + "preferences.followingLight": + "Votre appareil est en mode clair : la version claire est affichée. Choisir un thème choisit ses deux versions.", + "preferences.followingDark": + "Votre appareil est en mode sombre : la version sombre est affichée. Choisir un thème choisit ses deux versions.", + "preferences.standInLight": + "{theme} n’a pas de version claire : {fallback} le remplace tant que votre appareil est en mode clair.", + "preferences.standInDark": + "{theme} n’a pas de version sombre : {fallback} le remplace tant que votre appareil est en mode sombre.", "replayGain.label": "ReplayGain", "replayGain.off": "Désactivé", "replayGain.track": "Gain par piste", diff --git a/webapp/src/state/preferences.tsx b/webapp/src/state/preferences.tsx index 74a3df04..61241684 100644 --- a/webapp/src/state/preferences.tsx +++ b/webapp/src/state/preferences.tsx @@ -5,8 +5,10 @@ import { useContext, useEffect, useId, + useLayoutEffect, useState, } from "react"; +import { onMediaChange } from "../lib/media-query"; import { clampPreamp, PREAMP_MAX_DB, @@ -19,12 +21,20 @@ import { import { applyTheme, findTheme, - THEME_PRESETS, + type ThemeMode, type ThemePreset, + themeForScheme, } from "../styles/design-tokens"; import { type TranslationKey, useI18n } from "./i18n"; const STORAGE_KEY = "waveflow.theme"; +/** + * Per browser, beside the theme it qualifies: whether the device's light or + * dark setting chooses between a preset and its pair. A phone and a desktop + * on one account can disagree about it, as they can about the theme. + */ +const FOLLOW_SYSTEM_KEY = "waveflow.themeFollowSystem"; +const DARK_SCHEME = "(prefers-color-scheme: dark)"; /** * Per browser, like the volume and the theme: how loud a device should be is * not a property of the account (RFC-022 decision 1). @@ -32,8 +42,15 @@ const STORAGE_KEY = "waveflow.theme"; const REPLAY_GAIN_KEY = "waveflow.replayGain"; type Preferences = { + /** The preset on screen: the chosen one, or its pair while following. */ themeId: string; + /** The preset last picked, which is what is stored. */ + chosenThemeId: string; setThemeId: (id: string) => void; + followSystem: boolean; + setFollowSystem: (follow: boolean) => void; + /** The device's scheme, or null where the browser cannot say. */ + systemScheme: ThemeMode | null; replayGain: ReplayGainPreference; setReplayGain: (preference: ReplayGainPreference) => void; }; @@ -49,26 +66,93 @@ function readStoredReplayGain(): ReplayGainPreference { } } +function readStoredTheme(): ThemePreset { + try { + return findTheme(localStorage.getItem(STORAGE_KEY)); + } catch { + return findTheme(null); + } +} + +/** Off unless this browser turned it on: a first visit keeps the default. */ +function readStoredFollowSystem(): boolean { + try { + return localStorage.getItem(FOLLOW_SYSTEM_KEY) === "true"; + } catch { + return false; + } +} + +/** The device's scheme query, where the browser has one. */ +function schemeQuery(): MediaQueryList | null { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") + return null; + try { + return window.matchMedia(DARK_SCHEME); + } catch { + return null; + } +} + +function readSystemScheme(): ThemeMode | null { + const query = schemeQuery(); + return query ? (query.matches ? "dark" : "light") : null; +} + +/** + * The preset to paint: the chosen one, or — while following a scheme the + * browser can report — the member of its family that suits that scheme. + */ +function resolveTheme( + chosenId: string, + followSystem: boolean, + scheme: ThemeMode | null, +): ThemePreset { + const chosen = findTheme(chosenId); + return followSystem && scheme ? themeForScheme(chosen, scheme) : chosen; +} + export function PreferencesProvider({ children }: { children: ReactNode }) { - const [themeId, setStoredThemeId] = useState(() => { - let theme: ThemePreset; + const [chosenThemeId, setChosenThemeId] = useState( + () => readStoredTheme().id, + ); + const [followSystem, setFollowSystemState] = useState(readStoredFollowSystem); + const [systemScheme, setSystemScheme] = useState(readSystemScheme); + const [replayGain, setReplayGainState] = useState(readStoredReplayGain); + + const theme = resolveTheme(chosenThemeId, followSystem, systemScheme); + // Painted during the first render rather than after it, so the page never + // shows one frame of the default before the stored theme; then again + // whenever the choice, the box or the device changes it. + useState(() => applyTheme(theme)); + useLayoutEffect(() => applyTheme(theme), [theme]); + + // Live: a device that turns dark at sunset takes the page with it. Heard + // only while following, since nothing else reads the scheme. + useEffect(() => { + if (!followSystem) return; + const query = schemeQuery(); + if (!query) return; + const update = () => setSystemScheme(query.matches ? "dark" : "light"); + update(); + return onMediaChange(query, update); + }, [followSystem]); + + useEffect(() => { try { - theme = findTheme(localStorage.getItem(STORAGE_KEY)); + localStorage.setItem(STORAGE_KEY, chosenThemeId); } catch { - theme = findTheme(null); + // Appearance remains usable when persistent storage is disabled. } - applyTheme(theme); - return theme.id; - }); - const [replayGain, setReplayGainState] = useState(readStoredReplayGain); + }, [chosenThemeId]); useEffect(() => { try { - localStorage.setItem(STORAGE_KEY, themeId); + localStorage.setItem(FOLLOW_SYSTEM_KEY, String(followSystem)); } catch { - // Appearance remains usable when persistent storage is disabled. + // Following still works for this session without persistence. } - }, [themeId]); + }, [followSystem]); useEffect(() => { try { @@ -79,11 +163,20 @@ export function PreferencesProvider({ children }: { children: ReactNode }) { }, [replayGain]); const setThemeId = useCallback((id: string) => { - const theme = findTheme(id); - applyTheme(theme); - setStoredThemeId(theme.id); + setChosenThemeId(findTheme(id).id); }, []); + const setFollowSystem = useCallback( + (follow: boolean) => { + // Turning it off keeps what is on screen: the page should not jump back + // to the other half of the family the moment the box is cleared. + if (!follow) setChosenThemeId(theme.id); + else setSystemScheme(readSystemScheme()); + setFollowSystemState(follow); + }, + [theme], + ); + const setReplayGain = useCallback((preference: ReplayGainPreference) => { setReplayGainState({ mode: preference.mode, @@ -93,13 +186,38 @@ export function PreferencesProvider({ children }: { children: ReactNode }) { return ( {children} ); } +/** The theme preference, for the picker that sets it. */ +export function useThemePreference(): Pick< + Preferences, + | "themeId" + | "chosenThemeId" + | "setThemeId" + | "followSystem" + | "setFollowSystem" + | "systemScheme" +> { + const preferences = useContext(PreferencesContext); + if (!preferences) + throw new Error("useThemePreference requires PreferencesProvider"); + return preferences; +} + /** The ReplayGain preference, for the player that applies it. */ export function useReplayGainPreference(): ReplayGainPreference { const preferences = useContext(PreferencesContext); @@ -108,28 +226,6 @@ export function useReplayGainPreference(): ReplayGainPreference { return preferences.replayGain; } -export function ThemePicker() { - const preferences = useContext(PreferencesContext); - const { t } = useI18n(); - if (!preferences) throw new Error("ThemePicker requires PreferencesProvider"); - return ( - - ); -} - const MODE_LABELS: Record = { off: "replayGain.off", track: "replayGain.track", diff --git a/webapp/src/styles/design-tokens/apply.test.ts b/webapp/src/styles/design-tokens/apply.test.ts index 072bad62..cf6a0017 100644 --- a/webapp/src/styles/design-tokens/apply.test.ts +++ b/webapp/src/styles/design-tokens/apply.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { applyTheme, themeCssDeclarations } from "./apply"; +import { applyTheme, themeCssDeclarations, themeSurfaces } from "./apply"; import { findTheme, THEME_PRESETS } from "./themes"; describe("applyTheme", () => { @@ -82,3 +82,72 @@ describe("themeCssDeclarations", () => { expect(css).toContain("--color-surface-light: #ffffff;"); }); }); + +describe("themeSurfaces", () => { + afterEach(() => { + document.documentElement.removeAttribute("style"); + }); + + it("is what applyTheme hands the dark material for a dark preset", () => { + // The picker's miniatures draw from these and the page's surfaces read + // them: a card that disagreed would show a colour the page never paints. + const root = document.documentElement; + for (const preset of THEME_PRESETS.filter((t) => t.mode === "dark")) { + applyTheme(preset); + const surfaces = themeSurfaces(preset); + expect(root.style.getPropertyValue("--theme-ground")).toBe( + surfaces.ground, + ); + expect(root.style.getPropertyValue("--theme-surface")).toBe( + surfaces.surface, + ); + expect(root.style.getPropertyValue("--theme-raised")).toBe( + surfaces.raised, + ); + } + }); + + it("takes each dark preset's own surfaces, lifting the panel a step", () => { + const midnight = themeSurfaces(findTheme("midnight")); + expect(midnight.ground).toBe("#0b1020"); + expect(midnight.surface).toBe("color-mix(in srgb, #0b1020 97%, white)"); + expect(midnight.raised).toBe("#141a2e"); + // OLED keeps its true black ground. + expect(themeSurfaces(findTheme("oled")).ground).toBe("#000000"); + }); + + it("keeps the material's own near-black for a preset that names none", () => { + expect(themeSurfaces(findTheme("default-dark"))).toEqual({ + ground: "#0b0b0e", + surface: "#121216", + raised: "#17171d", + }); + }); + + it("clears the dark surfaces when a light preset follows a dark one", () => { + const root = document.documentElement; + applyTheme(findTheme("midnight")); + applyTheme(findTheme("midnight-light")); + for (const property of [ + "--theme-ground", + "--theme-surface", + "--theme-raised", + ]) + expect(root.style.getPropertyValue(property)).toBe(""); + }); + + it("names a light preset's raw tokens, as applyTheme writes them", () => { + const root = document.documentElement; + for (const preset of THEME_PRESETS.filter((t) => t.mode === "light")) { + applyTheme(preset); + const surfaces = themeSurfaces(preset); + expect(surfaces.ground).toBe(root.style.getPropertyValue("--ambient-bg")); + expect(surfaces.surface).toBe( + root.style.getPropertyValue("--color-surface-light"), + ); + expect(surfaces.raised).toBe( + root.style.getPropertyValue("--color-surface-light-elevated"), + ); + } + }); +}); diff --git a/webapp/src/styles/design-tokens/apply.ts b/webapp/src/styles/design-tokens/apply.ts index 8e0272c8..d371fa1d 100644 --- a/webapp/src/styles/design-tokens/apply.ts +++ b/webapp/src/styles/design-tokens/apply.ts @@ -52,6 +52,16 @@ export function applyTheme(theme: ThemePreset): void { "--color-surface-light-elevated", theme.surfaceLightElevated ?? DEFAULT_SURFACE_LIGHT_ELEVATED, ); + // The dark material's surfaces, written only for a dark preset and cleared + // otherwise, so a light page never carries a dark preset's leftovers. + const surfaces = themeSurfaces(theme); + for (const [key, property] of Object.entries(DARK_SURFACE_PROPERTIES)) { + if (theme.mode === "dark") { + root.style.setProperty(property, surfaces[key as keyof typeof surfaces]); + } else { + root.style.removeProperty(property); + } + } root.setAttribute("data-theme", theme.id); if (theme.mode === "dark") { root.classList.add("dark"); @@ -60,6 +70,61 @@ export function applyTheme(theme: ThemePreset): void { } } +/** + * The dark material's own surfaces (`fluid.css`), which a dark preset that + * names none of its own keeps: Emerald dark is the guide's near-black. + */ +const MATERIAL_DARK_GROUND = "#0b0b0e"; +const MATERIAL_DARK_SURFACE = "#121216"; +const MATERIAL_DARK_RAISED = "#17171d"; + +/** The variables the dark material reads its three surfaces from. */ +const DARK_SURFACE_PROPERTIES = { + ground: "--theme-ground", + surface: "--theme-surface", + raised: "--theme-raised", +} as const; + +/** + * The three surfaces a preset paints in its own mode — the page's ground, a + * panel and a raised panel. The Settings picker draws each miniature from + * these, so a card shows its own preset's colours whichever is applied. + * + * **For a dark preset these are the page's surfaces too**: `applyTheme` + * writes them as `--theme-ground`, `--theme-surface` and `--theme-raised`, + * which the dark material reads (`fluid.css`), so a card and the page it + * previews cannot disagree. The ground is the preset's `ambient`, the raised + * surface its `surfaceDarkElevated`. The panel is its `surfaceDark` lifted by + * the step the material puts between its own ground and panels (`#0b0b0e` to + * `#121216`, three parts in a hundred of white): every preset sets + * `surfaceDark` to its ambient, as the desktop's sidebar sits flush with its + * page, and taken as it is the web's panels and cards would vanish into the + * page. A preset that names none of them keeps the material's own. + * + * A light preset's values are its raw tokens; the light material tints its + * paper from `--ambient-bg` itself. + */ +export function themeSurfaces(theme: ThemePreset): { + ground: string; + surface: string; + raised: string; +} { + if (theme.mode === "dark") { + return { + ground: theme.ambient ?? MATERIAL_DARK_GROUND, + surface: theme.surfaceDark + ? `color-mix(in srgb, ${theme.surfaceDark} 97%, white)` + : MATERIAL_DARK_SURFACE, + raised: theme.surfaceDarkElevated ?? MATERIAL_DARK_RAISED, + }; + } + return { + ground: theme.ambient ?? "#ffffff", + surface: theme.surfaceLight ?? DEFAULT_SURFACE_LIGHT, + raised: theme.surfaceLightElevated ?? DEFAULT_SURFACE_LIGHT_ELEVATED, + }; +} + /** * Serialize a preset to a `