diff --git a/.tests/frontend/terminal-sexy.test.js b/.tests/frontend/terminal-sexy.test.js new file mode 100644 index 000000000..61a6a38bf --- /dev/null +++ b/.tests/frontend/terminal-sexy.test.js @@ -0,0 +1,146 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + clearTerminalSexyCatalogCache, + groupTerminalSexyCatalog, + importTerminalSexyTheme, + loadTerminalSexyCatalog, + normalizeTerminalSexyCatalog, + searchTerminalSexyThemes, + selectTerminalSexyFeaturedThemes, +} from "../../frontend/src/utils/terminalSexyThemes.js"; + +const originalFetch = globalThis.fetch; +const originalSetTimeout = globalThis.setTimeout; + +const scheme = { + name: "Solarized Dark", + background: "#002b36", + foreground: "#839496", + color: [ + "#073642", "#dc322f", "#859900", "#b58900", "#268bd2", "#d33682", "#2aa198", "#eee8d5", + "#002b36", "#cb4b16", "#586e75", "#657b83", "#839496", "#6c71c4", "#93a1a1", "#fdf6e3", + ], +}; + +test.afterEach(() => { + globalThis.fetch = originalFetch; + globalThis.setTimeout = originalSetTimeout; + clearTerminalSexyCatalogCache(); +}); + +test("terminal.sexy catalogs normalize and discard unsafe paths", () => { + const catalog = normalizeTerminalSexyCatalog(["base16/solarized.dark", "collection/Dawn", "../unsafe"]); + assert.deepEqual(catalog.map((entry) => entry.label), ["solarized", "Dawn"]); + assert.equal(catalog[0].appearance, "dark"); + assert.equal(catalog[1].category, "collection"); +}); + +test("terminal.sexy light and dark paths share one theme card", () => { + const catalog = normalizeTerminalSexyCatalog(["base16/solarized.dark", "base16/solarized.light"]); + const [group] = groupTerminalSexyCatalog(catalog); + assert.equal(group.label, "solarized"); + assert.deepEqual(Object.keys(group.sources), ["dark", "light"]); +}); + +test("terminal.sexy featured themes choose five stable schemes", () => { + const catalog = normalizeTerminalSexyCatalog([ + "base16/monokai.dark", + "base16/solarized.dark", + "base16/ocean.dark", + "collection/dawn", + "xcolors.net/zenburn", + "base16/3024.dark", + ]); + assert.deepEqual( + selectTerminalSexyFeaturedThemes(catalog).map((entry) => entry.path), + ["base16/monokai.dark", "base16/solarized.dark", "base16/ocean.dark", "collection/dawn", "xcolors.net/zenburn"], + ); +}); + +test("terminal.sexy schemes can be searched and imported with variants", async () => { + const lightScheme = { + ...scheme, + name: "Solarized Light", + background: "#fdf6e3", + foreground: "#657b83", + }; + globalThis.fetch = async (input) => { + const url = String(input); + if (url.endsWith("/index.json")) return new Response(JSON.stringify(["base16/solarized.dark", "base16/solarized.light"])); + if (url.endsWith("/base16/solarized.dark.json")) return new Response(JSON.stringify(scheme)); + if (url.endsWith("/base16/solarized.light.json")) return new Response(JSON.stringify(lightScheme)); + throw new Error(`Unexpected URL: ${url}`); + }; + + const [result] = await searchTerminalSexyThemes("solarized"); + assert.equal(result.label, "solarized"); + assert.deepEqual(Object.keys(result.sources), ["dark", "light"]); + const theme = await importTerminalSexyTheme(result); + assert.equal(theme.label, "solarized"); + assert.equal(theme.appearance, "light"); + assert.equal(theme.colors.chrome, "#fdf6e3"); + assert.equal(theme.variants.dark.chrome, "#002b36"); +}); + +test("paired terminal.sexy sources keep their declared light and dark modes", async () => { + const measuredDark = { ...scheme, name: "Measured dark light source", background: "#101010" }; + globalThis.fetch = async (input) => { + const url = String(input); + if (url.endsWith("/base16/solarized.dark.json")) return new Response(JSON.stringify(scheme)); + if (url.endsWith("/base16/solarized.light.json")) return new Response(JSON.stringify(measuredDark)); + throw new Error(`Unexpected URL: ${url}`); + }; + + const theme = await importTerminalSexyTheme({ + id: "terminal-sexy-solarized", + label: "solarized", + sources: { + dark: { path: "base16/solarized.dark" }, + light: { path: "base16/solarized.light" }, + }, + }); + + assert.equal(theme.appearance, "light"); + assert.equal(theme.colors.chrome, "#101010"); + assert.equal(theme.variants.dark.chrome, "#002b36"); +}); + +test("terminal.sexy catalog failures do not poison later retries", async () => { + let attempts = 0; + globalThis.fetch = async () => { + attempts += 1; + if (attempts === 1) return new Response("unavailable", { status: 503 }); + return new Response(JSON.stringify(["base16/solarized.dark"])); + }; + + await assert.rejects(loadTerminalSexyCatalog(), /unavailable/); + const catalog = await loadTerminalSexyCatalog(); + assert.equal(catalog[0].path, "base16/solarized.dark"); + assert.equal(attempts, 2); +}); + +test("terminal.sexy catalog rejects malformed and oversized responses", async () => { + globalThis.fetch = async () => new Response("not json"); + await assert.rejects(loadTerminalSexyCatalog(), /unavailable/); + + clearTerminalSexyCatalogCache(); + globalThis.fetch = async () => new Response(JSON.stringify(["x".repeat(256 * 1024)])); + await assert.rejects(loadTerminalSexyCatalog(), /unavailable/); +}); + +test("terminal.sexy catalog requests abort when they exceed the timeout", async () => { + let requestSignal; + globalThis.setTimeout = (callback) => { + callback(); + return 1; + }; + globalThis.fetch = async (_input, options) => { + requestSignal = options.signal; + throw new Error("aborted"); + }; + + await assert.rejects(loadTerminalSexyCatalog(), /unavailable/); + assert.equal(requestSignal.aborted, true); +}); diff --git a/.tests/frontend/theme-settings.test.js b/.tests/frontend/theme-settings.test.js new file mode 100644 index 000000000..9c05464f4 --- /dev/null +++ b/.tests/frontend/theme-settings.test.js @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +test("main Themes cards do not expose modal-only scheme actions", async () => { + const source = await readFile( + new URL("../../frontend/src/pages/Settings/components/ThemeSettings.jsx", import.meta.url), + "utf8", + ); + const sectionStart = source.indexOf('aria-labelledby="theme-themes-heading"'); + const sectionEnd = source.indexOf("= 0); + assert.ok(sectionEnd > sectionStart); + + const themesSection = source.slice(sectionStart, sectionEnd); + assert.equal(themesSection.includes(" { globalThis.document = originalDocument; globalThis.localStorage = originalLocalStorage; + invalidateThemeCaches(); }); -test("theme preferences default invalid stored values to system", () => { - assert.equal(normalizeThemePreference("light"), "light"); - assert.equal(normalizeThemePreference("dark"), "dark"); - assert.equal(normalizeThemePreference("sepia"), "system"); - - globalThis.localStorage = { getItem: () => "sepia" }; - assert.equal(getThemePreference(), "system"); +test("Aurral keeps the original light and dark palettes", () => { + const [aurral] = BUILT_IN_THEMES; + assert.deepEqual( + { + chrome: aurral.colors.chrome, + surface: aurral.colors.surface, + surfaceRaised: aurral.colors.surfaceRaised, + surfacePopover: aurral.colors.surfacePopover, + surfaceHover: aurral.colors.surfaceHover, + accent: aurral.colors.accent, + text: aurral.colors.text, + }, + { + chrome: "#e5e7eb", + surface: "#ffffff", + surfaceRaised: "#f1f1f1", + surfacePopover: "#e4e4e4", + surfaceHover: "#d0d0d0", + accent: "#4d7c0f", + text: "#171717", + }, + ); + assert.deepEqual( + { + chrome: aurral.variants.dark.chrome, + surface: aurral.variants.dark.surface, + surfaceRaised: aurral.variants.dark.surfaceRaised, + surfacePopover: aurral.variants.dark.surfacePopover, + surfaceHover: aurral.variants.dark.surfaceHover, + accent: aurral.variants.dark.accent, + text: aurral.variants.dark.text, + }, + { + chrome: "#000000", + surface: "#121212", + surfaceRaised: "#202020", + surfacePopover: "#2d2d2d", + surfaceHover: "#424242", + accent: "#84cc16", + text: "#ffffff", + }, + ); }); test("theme preferences persist explicit themes and clear the system override", () => { @@ -39,11 +84,134 @@ test("theme preferences persist explicit themes and clear the system override", setItem: (key, value) => stored.set(key, value), }; - assert.equal(setThemePreference("light"), "light"); + setThemeSelection("aurral", "light"); assert.equal(attributes.get("data-theme"), "light"); assert.equal(stored.get(THEME_STORAGE_KEY), "light"); - assert.equal(setThemePreference("system"), "system"); + setThemeSelection("aurral", "system"); assert.equal(attributes.has("data-theme"), false); assert.equal(stored.get(THEME_STORAGE_KEY), "system"); }); + +test("temporary theme previews change the page without changing saved selection", () => { + const attributes = new Map(); + const stored = new Map(); + const styles = new Map(); + globalThis.document = { + documentElement: { + style: { + setProperty: (name, value) => styles.set(name, value), + removeProperty: (name) => styles.delete(name), + }, + removeAttribute: (name) => attributes.delete(name), + setAttribute: (name, value) => attributes.set(name, value), + }, + querySelectorAll: () => [], + }; + globalThis.localStorage = { + getItem: (key) => stored.get(key) ?? null, + setItem: (key, value) => stored.set(key, value), + }; + + const preview = { + id: "terminal-sexy-preview", + label: "Preview", + appearance: "dark", + colors: { ...BUILT_IN_THEMES[0].variants.dark, accent: "#ff00aa" }, + }; + applyThemePreview(preview, "dark"); + + assert.equal(styles.get("--aurral-accent"), "#ff00aa"); + assert.equal(attributes.get("data-theme-id"), "terminal-sexy-preview"); + assert.equal(stored.has(THEME_STORAGE_KEY), false); + assert.deepEqual(getThemeSettings(), { themeId: "aurral", appearance: "system" }); +}); + +test("terminal.sexy ANSI colors become a complete Aurral palette", () => { + const source = { + name: "Example Dark", + background: "#1e1e1e", + foreground: "#d4d4d4", + color: [ + "#000000", "#dc322f", "#859900", "#b58900", "#268bd2", "#d33682", "#2aa198", "#eee8d5", + "#073642", "#cb4b16", "#586e75", "#657b83", "#839496", "#6c71c4", "#93a1a1", "#fdf6e3", + ], + }; + + const theme = parseTerminalSexyTheme(source); + assert.equal(theme.label, "Example Dark"); + assert.equal(theme.appearance, "dark"); + assert.equal(theme.colors.chrome, "#1e1e1e"); + assert.equal(theme.colors.accent, "#268bd2"); + assert.equal(theme.colors.danger, "#dc322f"); + assert.equal(Object.keys(theme.colors).length, BUILT_IN_THEMES[0] && Object.keys(BUILT_IN_THEMES[0].colors).length); +}); + +test("custom themes persist and restore their selected mode", () => { + const stored = new Map(); + globalThis.localStorage = { + getItem: (key) => stored.get(key) ?? null, + setItem: (key, value) => stored.set(key, value), + removeItem: (key) => stored.delete(key), + }; + globalThis.document = { + documentElement: { + style: { setProperty() {}, removeProperty() {} }, + setAttribute() {}, + removeAttribute() {}, + }, + querySelectorAll: () => [], + }; + + const theme = installCustomTheme({ + id: "midnight-garden", + label: "Midnight garden", + appearance: "dark", + colors: { accent: "#6cc5ff" }, + }); + assert.equal(stored.has(CUSTOM_THEMES_STORAGE_KEY), true); + assert.equal(getCustomThemes()[0].id, theme.id); + + replaceCustomTheme({ ...theme, variants: { light: { accent: "#e5b567" } } }); + assert.equal(getCustomThemes()[0].variants.light.accent, "#e5b567"); + + setThemeSelection(theme.id, "dark"); + assert.deepEqual(getThemeSettings(), { themeId: theme.id, appearance: "dark" }); +}); + +test("theme bootstrap restores a saved custom palette before app startup", async () => { + const source = await readFile(new URL("../../frontend/public/theme.js", import.meta.url), "utf8"); + const values = new Map(); + const dataset = {}; + const storage = new Map([ + [THEME_STORAGE_KEY, "terminal-sexy-custom"], + ["aurralThemeAppearance:v1", "dark"], + [CUSTOM_THEMES_STORAGE_KEY, JSON.stringify([{ + version: 1, + id: "terminal-sexy-custom", + name: "Custom", + appearance: "light", + colors: { chrome: "#eeeeee", surface: "#ffffff", accent: "#315fcb" }, + variants: { dark: { chrome: "#101010", surface: "#181818", accent: "#6cc5ff" } }, + }])], + ]); + + runInNewContext(source, { + localStorage: { getItem: (key) => storage.get(key) ?? null }, + matchMedia: () => ({ matches: false }), + document: { + documentElement: { + dataset, + style: { + setProperty: (name, value) => values.set(name, value), + removeProperty: () => {}, + }, + }, + }, + }); + + assert.equal(dataset.theme, "dark"); + assert.equal(dataset.themeId, "terminal-sexy-custom"); + assert.equal(values.get("--aurral-chrome"), "#101010"); + assert.equal(values.get("--aurral-accent"), "#6cc5ff"); +}); diff --git a/docs/src/content/docs/using/overview.mdx b/docs/src/content/docs/using/overview.mdx index 269d0fd7c..0638c4e5d 100644 --- a/docs/src/content/docs/using/overview.mdx +++ b/docs/src/content/docs/using/overview.mdx @@ -60,9 +60,12 @@ When you configure Ticketmaster, this section shows nearby concerts for specifie ![Aurral profile listening history](../../../assets/screenshots/profile-listening.webp) -Profile contains appearance, listening history, Lidarr defaults, and personal preferences. Select -**System**, **Light**, or **Dark** under **Appearance**. System follows the appearance setting of -your device. Aurral saves the theme on the current device. +Profile contains appearance, listening history, Lidarr defaults, and personal preferences. Under +**Appearance**, choose a built-in palette, **System**, **Light**, or **Dark** mode. Aurral saves the +selection on the current device. Browse the pre-existing terminal.sexy color schemes, preview them, +and select them like any other theme. Matching light and dark schemes share one card and follow the +selected mode; use **Find a scheme** to search the collection, preview results, and add the ones you +want to keep in a modal. Aurral supports Last.fm, ListenBrainz, and Koito history. diff --git a/frontend/public/theme.js b/frontend/public/theme.js index 9bea5d324..03f9cc07a 100644 --- a/frontend/public/theme.js +++ b/frontend/public/theme.js @@ -1,4 +1,31 @@ try { const theme = localStorage.getItem("aurralTheme"); - if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme; + const appearance = localStorage.getItem("aurralThemeAppearance:v1"); + const systemMode = typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + const requestedMode = theme === "light" || theme === "dark" + ? theme + : appearance === "light" || appearance === "dark" + ? appearance + : systemMode; + const root = document.documentElement; + let mode = requestedMode; + + if (theme && theme !== "system" && theme !== "light" && theme !== "dark") { + const storedThemes = JSON.parse(localStorage.getItem("aurralThemes:v1") || "[]"); + const selected = Array.isArray(storedThemes) ? storedThemes.find((item) => item?.id === theme) : null; + const colors = selected?.variants?.[requestedMode] || selected?.colors; + if (selected?.variants?.[requestedMode]) mode = requestedMode; + else if (selected?.appearance === "light" || selected?.appearance === "dark") mode = selected.appearance; + if (root.style && colors && typeof colors === "object") { + for (const [role, value] of Object.entries(colors)) { + if (/^#[\da-f]{3,8}$/i.test(value)) { + root.style.setProperty(`--aurral-${role.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`, value); + } + } + } + root.dataset.themeId = theme; + } + + root.dataset.theme = mode; + if (root.style) root.style.colorScheme = mode; } catch {} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 03f49fd44..f29047aee 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -2,6 +2,9 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.jsx"; import "./index.css"; +import { initializeTheme } from "./utils/theme.js"; + +initializeTheme(); if (!window.location.pathname.endsWith("/oauth.html")) { ReactDOM.createRoot(document.getElementById("root")).render( diff --git a/frontend/src/pages/Settings/components/SettingsAccountTab.jsx b/frontend/src/pages/Settings/components/SettingsAccountTab.jsx index 7de4cb8df..c400bc66d 100644 --- a/frontend/src/pages/Settings/components/SettingsAccountTab.jsx +++ b/frontend/src/pages/Settings/components/SettingsAccountTab.jsx @@ -1,12 +1,9 @@ import { useState } from "react"; import { resetDiscoveryFeedback } from "../../../utils/api/endpoints/discovery.js"; -import { - getThemePreference, - setThemePreference, -} from "../../../utils/theme.js"; import { SettingsInput, SettingsSelect } from "./SettingsField"; import PillToggle from "../../../components/PillToggle"; import { PlexSelfLinkSection } from "./PlexSelfLinkSection"; +import { ThemeSettings } from "./ThemeSettings"; import { Link } from "react-router-dom"; import { RotateCcw } from "lucide-react"; @@ -35,7 +32,6 @@ export function SettingsAccountTab({ setSidebarArtEnabled, }) { const [resettingTastes, setResettingTastes] = useState(false); - const [theme, setTheme] = useState(getThemePreference); const handleResetDiscoveryTastes = async () => { if (resettingTastes) return; @@ -91,25 +87,9 @@ export function SettingsAccountTab({

Appearance

-
-
- - setTheme(setThemePreference(event.target.value))} - > - - - - -

- System follows the appearance setting of your device. -

-
- {profileVariant && showSidebarArt ? ( + + {profileVariant && showSidebarArt ? ( +
- ) : null} -
+
+ ) : null}
diff --git a/frontend/src/pages/Settings/components/ThemeSettings.jsx b/frontend/src/pages/Settings/components/ThemeSettings.jsx new file mode 100644 index 000000000..6b00f704b --- /dev/null +++ b/frontend/src/pages/Settings/components/ThemeSettings.jsx @@ -0,0 +1,612 @@ +import { createPortal } from "react-dom"; +import { useEffect, useId, useRef, useState } from "react"; +import { Check, Loader2, Monitor, Moon, Palette, Plus, Search, Sun, Trash2, X } from "lucide-react"; +import TooltipButton from "../../../components/TooltipButton.jsx"; +import { useModalDialog } from "../../../hooks/useModalDialog.js"; +import { + applyThemePreview, + applyThemeSelection, + BUILT_IN_THEMES, + getCustomThemes, + getThemeColorsForMode, + getThemeSettings, + installCustomTheme, + removeCustomTheme, + replaceCustomTheme, + setThemeSelection, + THEME_APPEARANCES, + subscribeToCustomThemes, + subscribeToThemeChanges, +} from "../../../utils/theme.js"; +import { + importTerminalSexyTheme, + loadTerminalSexyCatalog, + searchTerminalSexyThemes, + selectTerminalSexyFeaturedThemes, +} from "../../../utils/terminalSexyThemes.js"; +import "./themeSettings.css"; + +const APPEARANCE_OPTIONS = [ + { id: "system", label: "System", Icon: Monitor }, + { id: "light", label: "Light", Icon: Sun }, + { id: "dark", label: "Dark", Icon: Moon }, +].filter((option) => THEME_APPEARANCES.includes(option.id)); + +function previewMode(theme, mode) { + if (mode === "dark" && getThemeColorsForMode(theme, "dark")) return "dark"; + if (mode === "light" && getThemeColorsForMode(theme, "light")) return "light"; + return theme.appearance; +} + +function ModePreview({ appearance }) { + const light = getThemeColorsForMode(BUILT_IN_THEMES[0], "light"); + const dark = getThemeColorsForMode(BUILT_IN_THEMES[0], "dark"); + const colors = appearance === "dark" ? dark : light; + return ( +