diff --git a/CHANGELOG.md b/CHANGELOG.md index 0465cf3e..c2fd2d8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,11 +56,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Themes — community Theme Store -**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014)** +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015)** * New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews. * The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline. -* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is fully validated against the theme contract, you confirm its name and author, then it installs like any other community theme. +* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is validated, you confirm its name and author, then it installs like any other community theme. +* Themes are **free-form** — beyond recolouring, they can add their own styling and animations and react to playback / fullscreen / sidebar / lyrics state. A safety floor (no network, no scripts) is always enforced; store themes are reviewed, and imported themes install at your own risk. * The store paginates large catalogues, and refreshing the list no longer jumps back to the top. * Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light). diff --git a/src/App.tsx b/src/App.tsx index 08816e32..7193db6a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,7 @@ import { useEffect } from 'react'; import { useAuthStore } from './store/authStore'; +import { usePlayerStore } from './store/playerStore'; +import { useLyricsStore } from './store/lyricsStore'; import { useThemeStore } from './store/themeStore'; import { useInstalledThemesStore } from './store/installedThemesStore'; import { syncInjectedThemes } from './utils/themes/themeInjection'; @@ -34,6 +36,25 @@ export default function App() { document.documentElement.setAttribute('data-theme', effectiveTheme); }, [effectiveTheme]); + // Expose app state on the theme root so themes can react to it with a + // same-element compound selector, e.g. `[data-theme='x'][data-playing='true']`. + // The set of allowed state attributes is the contract's `stateSelectors`. + // (Sidebar-collapsed is set in AppShell, where that state lives.) + const isPlaying = usePlayerStore(s => s.isPlaying); + useEffect(() => { + document.documentElement.setAttribute('data-playing', isPlaying ? 'true' : 'false'); + }, [isPlaying]); + + const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen); + useEffect(() => { + document.documentElement.setAttribute('data-fullscreen', isFullscreenOpen ? 'true' : 'false'); + }, [isFullscreenOpen]); + + const lyricsOpen = useLyricsStore(s => s.activeTab === 'lyrics'); + useEffect(() => { + document.documentElement.setAttribute('data-lyrics-open', lyricsOpen ? 'true' : 'false'); + }, [lyricsOpen]); + useEffect(() => { document.documentElement.setAttribute('data-font', font); }, [font]); diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 89ed4ab8..f2c71d15 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -189,6 +189,13 @@ export function AppShell() { return () => window.removeEventListener('psy:toggle-sidebar', onToggleSidebar); }, [isSidebarCollapsed, setSidebarCollapsed]); + // Expose sidebar state on the theme root so themes can react with a + // `[data-theme='x'][data-sidebar-collapsed='true']` compound (contract + // `stateSelectors`). Other state attributes are set in App.tsx. + useEffect(() => { + document.documentElement.setAttribute('data-sidebar-collapsed', isSidebarCollapsed ? 'true' : 'false'); + }, [isSidebarCollapsed]); + // Workaround for WebKitGTK 2.50.x text-input repaint hang on // Linux Mint / Ubuntu 24.04 (issues #342, #782). When opted in, // nudge WebKit awake on every input/textarea focus via a sync diff --git a/src/components/settings/ThemeImportSection.tsx b/src/components/settings/ThemeImportSection.tsx index e80dc5a4..d65aa3c7 100644 --- a/src/components/settings/ThemeImportSection.tsx +++ b/src/components/settings/ThemeImportSection.tsx @@ -132,7 +132,7 @@ export function ThemeImportSection() { injection sync. + * Tests for the runtime theme-CSS security floor and injection sync. + * Community themes are free-form; the floor only blocks the hard safety + * invariants (network, scripts, breakout, unscoped @keyframes, size). */ import { afterEach, describe, expect, it } from 'vitest'; import { @@ -21,56 +23,67 @@ afterEach(() => { document.head.querySelectorAll(`style[${ATTR}]`).forEach((el) => el.remove()); }); -describe('validateThemeCss', () => { - it('accepts a valid single scoped rule', () => { +describe('validateThemeCss (security floor)', () => { + it('accepts a simple scoped rule', () => { expect(validateThemeCss(block('dracula'), 'dracula')).not.toBeNull(); }); - it('accepts a data: url on the contract (e.g. --select-arrow)', () => { + it('accepts free-form selectors and structure', () => { + const css = `html { color: red; } .sidebar { background: #000; } [data-theme='x'] .player-bar { opacity: 0.9; }`; + expect(validateThemeCss(css, 'x')).not.toBeNull(); + }); + + it('accepts @media and multiple rules', () => { + const css = `${block('x')} @media (min-width: 600px) { .sidebar { width: 200px; } }`; + expect(validateThemeCss(css, 'x')).not.toBeNull(); + }); + + it('accepts @keyframes namespaced with the theme id, and its animation use', () => { + const css = `@keyframes x-pulse { from { opacity: 1 } to { opacity: 0.5 } } .sidebar { animation: x-pulse 2s infinite; }`; + expect(validateThemeCss(css, 'x')).not.toBeNull(); + }); + + it('accepts a data: url()', () => { const css = block('x', `--select-arrow: url("data:image/svg+xml,%3Csvg%3E%3C/svg%3E");`); expect(validateThemeCss(css, 'x')).not.toBeNull(); }); + it('rejects @keyframes not namespaced with the theme id', () => { + expect(validateThemeCss(`@keyframes pulse { from {} to {} } ${block('x')}`, 'x')).toBeNull(); + }); + it('rejects @import', () => { expect(validateThemeCss(`@import 'evil.css'; ${block('x')}`, 'x')).toBeNull(); }); + it('rejects @property (global custom-prop registration)', () => { + const css = `@property --x { syntax: ''; inherits: false; initial-value: red; } ${block('x')}`; + expect(validateThemeCss(css, 'x')).toBeNull(); + }); + it('rejects a non-data url()', () => { expect(validateThemeCss(block('x', `--accent: url(https://evil.test/x.png);`), 'x')).toBeNull(); }); - it('rejects breakout', () => { + it('rejects /