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)
This commit is contained in:
Psychotoxical
2026-07-06 01:45:34 +02:00
committed by GitHub
parent 41ae30f05d
commit a874192408
18 changed files with 160 additions and 4 deletions
+8
View File
@@ -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
+1
View File
@@ -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)',
],
},
{
@@ -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(<ThemeStoreSection />);
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(<ThemeStoreSection />);
await screen.findByText('Zulu');
// Without the pin, newest-first would order Alpha (06-10) before Zulu (06-01).
expect(rowNames(container)).toEqual(['Zulu', 'Alpha']);
});
});
@@ -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[]>): [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<string | null>(null);
const [failedId, setFailedId] = useState<string | null>(null);
const [lightbox, setLightbox] = useState<{ src: string; name: string } | null>(null);
const [openChangelogIds, setOpenChangelogIds] = useState<Set<string>>(() => new Set());
const topRef = useRef<HTMLDivElement>(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 (
<div
key={th.id}
@@ -370,6 +396,43 @@ export function ThemeStoreSection() {
{t('settings.themeStoreLastChanged')}: {formatRelativeTime(th.updatedAt, i18n.language)}
</div>
)}
{changelogEntries.length > 0 && (
<div style={{ marginTop: 10 }}>
<button
type="button"
className="btn btn-ghost"
aria-expanded={changelogOpen}
aria-controls={`theme-changelog-${th.id}`}
onClick={() => toggleChangelog(th.id)}
style={{ fontSize: 12, padding: '2px 8px', display: 'inline-flex', alignItems: 'center', gap: 5 }}
>
<ChevronDown
size={14}
style={{ transform: changelogOpen ? 'rotate(180deg)' : 'none', transition: 'transform 120ms' }}
/>
{t('settings.themeStoreWhatsNew')}
</button>
{changelogOpen && (
<div
id={`theme-changelog-${th.id}`}
style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}
>
{changelogEntries.map(([version, lines]) => (
<div key={version}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>
v{version}
</div>
<ul style={{ margin: '2px 0 0', paddingInlineStart: 18, fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>
{lines.map((line, i) => (
<li key={i}>{line}</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 12 }}>
{!isInstalled && (
<button
+6
View File
@@ -30,6 +30,12 @@ export interface RegistryTheme {
thumbnail: string;
/** ISO date of the last commit touching the theme in the registry repo. */
updatedAt?: string;
/**
* Optional per-version release notes, keyed by `X.Y.Z`. Rendered as the
* expandable "What's new" on the theme card. Author-provided in the manifest;
* absent for themes that don't ship one.
*/
changelog?: Record<string, string[]>;
}
export interface Registry {
+1
View File
@@ -463,6 +463,7 @@ export const settings = {
themeStoreAnimStatic: 'Статични',
themeStoreByAuthor: 'от {{author}}',
themeStoreLastChanged: 'Последна промяна',
themeStoreWhatsNew: 'Какво ново',
themeMigrationNoticeTitle: 'Темата беше преместена в Магазина',
themeMigrationNoticeBody: '{{themes}} вече не се доставя вградено с приложението. Можеш да я преинсталираш от Магазина за теми (Настройки → Теми) или да избереш друга тема.',
themeMigrationNoticeOpen: 'Отвори Магазина за теми',
+1
View File
@@ -419,6 +419,7 @@ export const settings = {
themeStoreAnimStatic: 'Statisch',
themeStoreByAuthor: 'von {{author}}',
themeStoreLastChanged: 'Zuletzt geändert',
themeStoreWhatsNew: 'Was ist neu',
themeMigrationNoticeTitle: 'Theme in den Store gezogen',
themeMigrationNoticeBody: '{{themes}} ist nicht mehr in der App enthalten. Du kannst es im Theme-Store neu installieren (Einstellungen → Themes) oder ein anderes Theme wählen.',
themeMigrationNoticeOpen: 'Theme-Store öffnen',
+1
View File
@@ -463,6 +463,7 @@ export const settings = {
themeStoreAnimStatic: 'Static',
themeStoreByAuthor: 'by {{author}}',
themeStoreLastChanged: 'Last changed',
themeStoreWhatsNew: "What's new",
themeMigrationNoticeTitle: 'Theme moved to the Store',
themeMigrationNoticeBody: '{{themes}} is no longer bundled with the app. You can reinstall it from the Theme Store (Settings → Themes), or pick another theme.',
themeMigrationNoticeOpen: 'Open Theme Store',
+1
View File
@@ -417,6 +417,7 @@ export const settings = {
themeStoreAnimStatic: 'Estáticos',
themeStoreByAuthor: 'por {{author}}',
themeStoreLastChanged: 'Última modificación',
themeStoreWhatsNew: 'Novedades',
themeMigrationNoticeTitle: 'Tema movido a la tienda',
themeMigrationNoticeBody: '{{themes}} ya no viene incluido en la app. Puedes reinstalarlo desde la Tienda de Temas (Ajustes → Temas) o elegir otro tema.',
themeMigrationNoticeOpen: 'Abrir Tienda de Temas',
+1
View File
@@ -405,6 +405,7 @@ export const settings = {
themeStoreAnimStatic: 'Statiques',
themeStoreByAuthor: 'par {{author}}',
themeStoreLastChanged: 'Dernière modification',
themeStoreWhatsNew: 'Nouveautés',
themeMigrationNoticeTitle: 'Thème déplacé vers la boutique',
themeMigrationNoticeBody: "{{themes}} n'est plus inclus dans l'application. Vous pouvez le réinstaller depuis la boutique de thèmes (Paramètres → Thèmes) ou choisir un autre thème.",
themeMigrationNoticeOpen: 'Ouvrir la boutique de thèmes',
+1
View File
@@ -463,6 +463,7 @@ export const settings = {
themeStoreAnimStatic: 'Statikus',
themeStoreByAuthor: 'készítette: {{author}}',
themeStoreLastChanged: 'Utoljára módosítva',
themeStoreWhatsNew: 'Újdonságok',
themeMigrationNoticeTitle: 'A téma átkerült a Boltba',
themeMigrationNoticeBody: 'A(z) {{themes}} már nincs az alkalmazáshoz csomagolva. Újratelepítheted a Témaboltból (Beállítások → Témák), vagy válassz másik témát.',
themeMigrationNoticeOpen: 'Témabolt megnyitása',
+1
View File
@@ -457,6 +457,7 @@ export const settings = {
themeStoreAnimStatic: '静的',
themeStoreByAuthor: '{{author}} 作',
themeStoreLastChanged: '最終変更',
themeStoreWhatsNew: '新着情報',
themeMigrationNoticeTitle: 'テーマはストアへ移動しました',
themeMigrationNoticeBody: '{{themes}} はアプリに同梱されなくなりました。テーマストア (設定 → テーマ) から再インストールするか、別のテーマを選んでください。',
themeMigrationNoticeOpen: 'テーマストアを開く',
+1
View File
@@ -408,6 +408,7 @@ export const settings = {
themeStoreAnimStatic: 'Statiske',
themeStoreByAuthor: 'av {{author}}',
themeStoreLastChanged: 'Sist endret',
themeStoreWhatsNew: 'Hva er nytt',
themeMigrationNoticeTitle: 'Temaet er flyttet til butikken',
themeMigrationNoticeBody: '{{themes}} følger ikke lenger med appen. Du kan installere det på nytt fra temabutikken (Innstillinger → Temaer), eller velge et annet tema.',
themeMigrationNoticeOpen: 'Åpne temabutikken',
+1
View File
@@ -405,6 +405,7 @@ export const settings = {
themeStoreAnimStatic: 'Statisch',
themeStoreByAuthor: 'door {{author}}',
themeStoreLastChanged: 'Laatst gewijzigd',
themeStoreWhatsNew: 'Wat is er nieuw',
themeMigrationNoticeTitle: 'Thema verplaatst naar de store',
themeMigrationNoticeBody: '{{themes}} wordt niet meer met de app meegeleverd. Je kunt het opnieuw installeren via de Theme Store (Instellingen → Thema\'s) of een ander thema kiezen.',
themeMigrationNoticeOpen: 'Theme Store openen',
+1
View File
@@ -463,6 +463,7 @@ export const settings = {
themeStoreAnimStatic: 'Statyczny',
themeStoreByAuthor: 'autorstwa {{author}}',
themeStoreLastChanged: 'Ostatnio zmieniony',
themeStoreWhatsNew: 'Co nowego',
themeMigrationNoticeTitle: 'Motyw przeniesiony do sklepu',
themeMigrationNoticeBody: 'Motyw {{themes}} nie jest już dołączany do aplikacji. Możesz zainstalować go ponownie ze Sklepu motywów (Ustawienia → Motywy) albo wybrać inny motyw.',
themeMigrationNoticeOpen: 'Otwórz sklep motywów',
+1
View File
@@ -421,6 +421,7 @@ export const settings = {
themeStoreAnimStatic: 'Statice',
themeStoreByAuthor: 'de {{author}}',
themeStoreLastChanged: 'Ultima modificare',
themeStoreWhatsNew: 'Noutăți',
themeMigrationNoticeTitle: 'Tema a fost mutată în magazin',
themeMigrationNoticeBody: '{{themes}} nu mai este inclusă în aplicație. O poți reinstala din Magazinul de Teme (Setări → Teme) sau poți alege altă temă.',
themeMigrationNoticeOpen: 'Deschide Magazinul de Teme',
+1
View File
@@ -473,6 +473,7 @@ export const settings = {
themeStoreAnimStatic: 'Статичные',
themeStoreByAuthor: 'от {{author}}',
themeStoreLastChanged: 'Изменён',
themeStoreWhatsNew: 'Что нового',
themeMigrationNoticeTitle: 'Тема перенесена в магазин',
themeMigrationNoticeBody: '{{themes}} больше не входит в приложение. Вы можете переустановить её из магазина тем (Настройки → Темы) или выбрать другую тему.',
themeMigrationNoticeOpen: 'Открыть магазин тем',
+1
View File
@@ -404,6 +404,7 @@ export const settings = {
themeStoreAnimStatic: '静态',
themeStoreByAuthor: '作者:{{author}}',
themeStoreLastChanged: '最后更新',
themeStoreWhatsNew: '更新内容',
themeMigrationNoticeTitle: '主题已移至商店',
themeMigrationNoticeBody: '{{themes}} 不再内置于应用中。你可以在主题商店(设置 → 主题)重新安装,或选择其他主题。',
themeMigrationNoticeOpen: '打开主题商店',