From 9df69e90567cde6f6122ddec932a539cbf4668ad Mon Sep 17 00:00:00 2001 From: Lee Kelly Date: Wed, 19 Aug 2026 19:33:47 +0000 Subject: [PATCH 1/5] feat(settings): add terminal.sexy theme system --- .tests/frontend/terminal-sexy.test.js | 82 +++ .tests/frontend/theme.test.js | 141 ++++ docs/src/content/docs/using/overview.mdx | 8 +- frontend/public/theme.js | 13 +- frontend/src/main.jsx | 3 + .../components/SettingsAccountTab.jsx | 32 +- .../Settings/components/ThemeSettings.jsx | 646 +++++++++++++++++ .../Settings/components/themeSettings.css | 659 ++++++++++++++++++ frontend/src/utils/terminalSexyThemes.js | 234 +++++++ frontend/src/utils/theme.js | 642 ++++++++++++++++- 10 files changed, 2419 insertions(+), 41 deletions(-) create mode 100644 .tests/frontend/terminal-sexy.test.js create mode 100644 frontend/src/pages/Settings/components/ThemeSettings.jsx create mode 100644 frontend/src/pages/Settings/components/themeSettings.css create mode 100644 frontend/src/utils/terminalSexyThemes.js diff --git a/.tests/frontend/terminal-sexy.test.js b/.tests/frontend/terminal-sexy.test.js new file mode 100644 index 000000000..014a9e7b4 --- /dev/null +++ b/.tests/frontend/terminal-sexy.test.js @@ -0,0 +1,82 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + clearTerminalSexyCatalogCache, + groupTerminalSexyCatalog, + importTerminalSexyTheme, + normalizeTerminalSexyCatalog, + searchTerminalSexyThemes, + selectTerminalSexyFeaturedThemes, +} from "../../frontend/src/utils/terminalSexyThemes.js"; + +const originalFetch = globalThis.fetch; + +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; + 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"); +}); diff --git a/.tests/frontend/theme.test.js b/.tests/frontend/theme.test.js index a6f3c0ede..b6dfdf6fc 100644 --- a/.tests/frontend/theme.test.js +++ b/.tests/frontend/theme.test.js @@ -2,11 +2,21 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + applyThemePreview, + BUILT_IN_THEMES, + CUSTOM_THEMES_STORAGE_KEY, + getCustomThemes, + getThemeSettings, + installCustomTheme, + invalidateThemeCaches, + replaceCustomTheme, THEME_STORAGE_KEY, getThemePreference, normalizeThemePreference, + setThemeSelection, setThemePreference, } from "../../frontend/src/utils/theme.js"; +import { parseTerminalSexyTheme } from "../../frontend/src/utils/terminalSexyThemes.js"; const originalDocument = globalThis.document; const originalLocalStorage = globalThis.localStorage; @@ -14,6 +24,7 @@ const originalLocalStorage = globalThis.localStorage; test.afterEach(() => { globalThis.document = originalDocument; globalThis.localStorage = originalLocalStorage; + invalidateThemeCaches(); }); test("theme preferences default invalid stored values to system", () => { @@ -25,6 +36,50 @@ test("theme preferences default invalid stored values to system", () => { 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", () => { const attributes = new Map(); const stored = new Map(); @@ -47,3 +102,89 @@ test("theme preferences persist explicit themes and clear the system override", 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" }); +}); diff --git a/docs/src/content/docs/using/overview.mdx b/docs/src/content/docs/using/overview.mdx index 269d0fd7c..f6f6ab536 100644 --- a/docs/src/content/docs/using/overview.mdx +++ b/docs/src/content/docs/using/overview.mdx @@ -60,9 +60,11 @@ 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 add the ones you want to keep. Matching light and dark schemes share one card and follow the +selected mode; use **Find a scheme** to search the collection 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..340026f1a 100644 --- a/frontend/public/theme.js +++ b/frontend/public/theme.js @@ -1,4 +1,15 @@ try { const theme = localStorage.getItem("aurralTheme"); - if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme; + const appearance = localStorage.getItem("aurralThemeAppearance:v1"); + const mode = theme === "light" || theme === "dark" + ? theme + : appearance === "light" || appearance === "dark" + ? appearance + : matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + document.documentElement.dataset.theme = mode; + if (theme && theme !== "system" && theme !== "light" && theme !== "dark") { + document.documentElement.dataset.themeId = theme; + } } 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..a6f43e687 --- /dev/null +++ b/frontend/src/pages/Settings/components/ThemeSettings.jsx @@ -0,0 +1,646 @@ +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, + createUniqueThemeName, + 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 ModePreviewPane() { + return ( + <> + + + + + + + + + + + + + + + + + + + + + + ); +} + +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 ( + + ); +} + +function AppearanceModeCard({ option, active, onSelect }) { + const Icon = option.Icon; + return ( + + ); +} + +function ThemeSwatch({ colors, loading = false }) { + return ( +