From a8741924085b5cd792bf93192c179cd82e9e9d6c Mon Sep 17 00:00:00 2001
From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
Date: Mon, 6 Jul 2026 01:45:34 +0200
Subject: [PATCH] feat(themes): surface installed-theme updates in the store
(#1240)
* feat(themes): show per-theme changelog and pin updatable themes
Surface theme updates in the store: each card gains an expandable "What's
new" built from the theme's optional manifest changelog (versions listed
newest first), and installed themes with a pending update now float to the
top of the list in both sort modes so they are easy to find.
* i18n(settings): add themeStoreWhatsNew across locales
New "What's new" label for the theme-store changelog disclosure, in all 13
locales.
* docs(changelog): add theme store changelog and pinned-updates entry (#1240)
---
CHANGELOG.md | 8 +++
src/config/settingsCredits.ts | 1 +
.../components/ThemeStoreSection.test.tsx | 65 +++++++++++++++++
.../settings/components/ThemeStoreSection.tsx | 71 +++++++++++++++++--
src/lib/themes/themeRegistry.ts | 6 ++
src/locales/bg/settings.ts | 1 +
src/locales/de/settings.ts | 1 +
src/locales/en/settings.ts | 1 +
src/locales/es/settings.ts | 1 +
src/locales/fr/settings.ts | 1 +
src/locales/hu/settings.ts | 1 +
src/locales/ja/settings.ts | 1 +
src/locales/nb/settings.ts | 1 +
src/locales/nl/settings.ts | 1 +
src/locales/pl/settings.ts | 1 +
src/locales/ro/settings.ts | 1 +
src/locales/ru/settings.ts | 1 +
src/locales/zh/settings.ts | 1 +
18 files changed, 160 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bfc96bba..79679d58 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,6 +47,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* A dismissible banner inviting you to join the Psysonic community on Discord appears after 20 hours of accumulated app use. **Join** opens the invite; dismiss it for the session, or choose **Never show again** to hide it permanently.
+### Theme Store — per-theme changelogs and pinned updates
+
+**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1240](https://github.com/Psychotoxical/psysonic/pull/1240)**
+
+* Each theme card now has an expandable **What's new** with per-version release notes, so you can see what a theme update changed — including non-visual fixes. Provided by theme authors; themes without notes just don't show the section.
+* Installed themes with an available update now appear at the top of the store list instead of wherever the sort placed them, so you don't have to hunt for them.
+
+
## Changed
### Frontend restructure — feature-folder architecture and hardening
diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts
index 32e0f677..ca382ec2 100644
--- a/src/config/settingsCredits.ts
+++ b/src/config/settingsCredits.ts
@@ -403,6 +403,7 @@ const CONTRIBUTOR_ENTRIES = [
'Themed window title bar on macOS — follows the active theme instead of the grey system bar, with the native window buttons floating over it (PR #1199)',
'Frontend feature-folder restructure — CI-enforced layering guard, added unit/behavior-scenario/boot-smoke tests, and a compile-time frontend/backend IPC contract via tauri-specta (PR #1225)',
'Completed the tauri-specta typed-IPC cutover — CI guards for bindings freshness and full command registration (PR #1230)',
+ 'Theme Store: per-theme changelogs shown as an expandable "What\'s new" on each card, plus installed themes with an available update pinned to the top of the list (PR #1240)',
],
},
{
diff --git a/src/features/settings/components/ThemeStoreSection.test.tsx b/src/features/settings/components/ThemeStoreSection.test.tsx
index 6d4cf076..4e477e5d 100644
--- a/src/features/settings/components/ThemeStoreSection.test.tsx
+++ b/src/features/settings/components/ThemeStoreSection.test.tsx
@@ -3,6 +3,7 @@ import { screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { ThemeStoreSection } from '@/features/settings/components/ThemeStoreSection';
+import { useInstalledThemesStore } from '@/store/installedThemesStore';
import type { FetchRegistryResult, Registry, RegistryTheme } from '@/lib/themes/themeRegistry';
// Control the registry the store browses so pagination/refresh are deterministic.
@@ -75,6 +76,7 @@ async function selectSort(
describe('ThemeStoreSection — pagination & refresh', () => {
beforeEach(() => {
vi.clearAllMocks();
+ useInstalledThemesStore.setState({ themes: [] });
// jsdom has no layout engine; goToPage() scrolls the list back up.
Element.prototype.scrollIntoView = vi.fn();
});
@@ -221,4 +223,67 @@ describe('ThemeStoreSection — pagination & refresh', () => {
expect(screen.queryByText('Theme 01')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '3', current: 'page' })).toBeInTheDocument();
});
+
+ it('shows an expandable changelog only for themes that ship one, newest version first', async () => {
+ const themes = [
+ mkTheme('with', 'With Log', {
+ changelog: { '1.0.0': ['First release'], '1.2.0': ['Newer note', 'Another newer note'] },
+ }),
+ mkTheme('without', 'No Log'),
+ ];
+ fetchRegistryMock.mockResolvedValue({ registry: registryOf(themes), stale: false });
+ const { container } = renderWithProviders();
+ const user = userEvent.setup();
+
+ await screen.findByText('With Log');
+
+ // Exactly one row (the one shipping a changelog) exposes the toggle.
+ const toggles = screen.getAllByRole('button', { name: "What's new" });
+ expect(toggles).toHaveLength(1);
+
+ // Collapsed by default — entry text is not rendered yet.
+ const toggle = toggles[0];
+ expect(toggle).toHaveAttribute('aria-expanded', 'false');
+ expect(screen.queryByText('Newer note')).not.toBeInTheDocument();
+
+ // Expanding lists every version, newest first (1.2.0 before 1.0.0).
+ await user.click(toggle);
+ expect(toggle).toHaveAttribute('aria-expanded', 'true');
+ const panel = container.querySelector('#theme-changelog-with') as HTMLElement;
+ expect(panel).not.toBeNull();
+ expect(panel.textContent).toContain('Newer note');
+ const i120 = panel.textContent!.indexOf('v1.2.0');
+ const i100 = panel.textContent!.indexOf('v1.0.0');
+ expect(i120).toBeGreaterThanOrEqual(0);
+ expect(i100).toBeGreaterThan(i120);
+ });
+
+ it('floats installed themes with a pending update to the top of the list', async () => {
+ // Alpha is the newest by date and would normally lead; Zulu is older but has
+ // an installed copy on an earlier version, so it should be pinned first.
+ const themes = [
+ mkTheme('alpha', 'Alpha', { updatedAt: '2026-06-10T00:00:00Z', version: '1.0.0' }),
+ mkTheme('zulu', 'Zulu', { updatedAt: '2026-06-01T00:00:00Z', version: '2.0.0' }),
+ ];
+ useInstalledThemesStore.setState({
+ themes: [
+ {
+ id: 'zulu',
+ name: 'Zulu',
+ author: 'Tester',
+ version: '1.0.0', // installed older than registry 2.0.0 → update available
+ description: '',
+ mode: 'dark',
+ css: 'x',
+ installedAt: 0,
+ },
+ ],
+ });
+ fetchRegistryMock.mockResolvedValue({ registry: registryOf(themes), stale: false });
+ const { container } = renderWithProviders();
+
+ await screen.findByText('Zulu');
+ // Without the pin, newest-first would order Alpha (06-10) before Zulu (06-01).
+ expect(rowNames(container)).toEqual(['Zulu', 'Alpha']);
+ });
});
diff --git a/src/features/settings/components/ThemeStoreSection.tsx b/src/features/settings/components/ThemeStoreSection.tsx
index 059ac06f..1e132e1c 100644
--- a/src/features/settings/components/ThemeStoreSection.tsx
+++ b/src/features/settings/components/ThemeStoreSection.tsx
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Check, ChevronLeft, ChevronRight, Download, RefreshCw, Trash2, WifiOff } from 'lucide-react';
+import { Check, ChevronDown, ChevronLeft, ChevronRight, Download, RefreshCw, Trash2, WifiOff } from 'lucide-react';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import CoverLightbox from '@/ui/CoverLightbox';
import { useThemeAnimationRisk } from '@/features/settings/hooks/useThemeAnimationRisk';
@@ -27,6 +27,11 @@ const THEMES_REPO_URL = 'https://github.com/Psysonic/psysonic-themes';
/** Themes shown per page — the catalogue is large enough to paginate. */
const PAGE_SIZE = 12;
+/** A theme's changelog as `[version, lines]` pairs, newest version first. */
+function sortedChangelog(changelog: Record): [string, string[]][] {
+ return Object.entries(changelog).sort(([a], [b]) => (isNewer(a, b) ? -1 : isNewer(b, a) ? 1 : 0));
+}
+
/** Page numbers for the pager: all of them when there are few, otherwise the
* first and last page plus a window around the current one, with gaps. */
function pageItemsList(current: number, total: number): (number | 'gap')[] {
@@ -67,8 +72,17 @@ export function ThemeStoreSection() {
const [busyId, setBusyId] = useState(null);
const [failedId, setFailedId] = useState(null);
const [lightbox, setLightbox] = useState<{ src: string; name: string } | null>(null);
+ const [openChangelogIds, setOpenChangelogIds] = useState>(() => new Set());
const topRef = useRef(null);
+ const toggleChangelog = (id: string) =>
+ setOpenChangelogIds(prev => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+
// A manual refresh must not unmount the list: blanking it collapses the
// scroll viewport's content height, which clamps scrollTop to 0 — i.e. the
// page jumps to the top. So keep the existing list mounted (`refreshing`,
@@ -119,9 +133,19 @@ export function ThemeStoreSection() {
// Name is the stable tie-breaker — keeps ordering deterministic when many
// themes share the same last-modified date.
const byName = (a: RegistryTheme, b: RegistryTheme) => a.name.localeCompare(b.name);
- if (sortMode === 'name') return matched.sort(byName);
- return matched.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '') || byName(a, b));
- }, [themes, query, mode, sortMode, animFilter]);
+ const bySort =
+ sortMode === 'name'
+ ? byName
+ : (a: RegistryTheme, b: RegistryTheme) =>
+ (b.updatedAt || '').localeCompare(a.updatedAt || '') || byName(a, b);
+ // Installed themes with a pending update float to the top (in either sort
+ // mode) so the user finds them instead of hunting through the catalogue.
+ const hasUpdate = (th: RegistryTheme) => {
+ const inst = installedMap.get(th.id);
+ return inst ? isNewer(th.version, inst.version) : false;
+ };
+ return matched.sort((a, b) => Number(hasUpdate(b)) - Number(hasUpdate(a)) || bySort(a, b));
+ }, [themes, query, mode, sortMode, animFilter, installedMap]);
// A changed filter can shrink the result set below the current page; reset to
// the first page whenever the query or mode filter changes.
@@ -308,6 +332,8 @@ export function ThemeStoreSection() {
const updateAvailable = isInstalled && isNewer(th.version, inst!.version);
const isActive = activeTheme === th.id;
const busy = busyId === th.id;
+ const changelogEntries = th.changelog ? sortedChangelog(th.changelog) : [];
+ const changelogOpen = openChangelogIds.has(th.id);
return (