mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(themes): community Theme Store + semantic-token refactor (#1009)
* feat(themes): add semantic tokens for the theme-store contract (B0 P1) Additive: define --highlight, --accent-2, --bg-deep, --bg-elevated and --text-on-accent on the :root base as --ctp-* mappings. They resolve per-theme automatically and nothing consumes them yet (zero behaviour change) — groundwork for replacing direct --ctp-* use in components. * refactor(themes): components consume semantic tokens, not --ctp-* (B0 P2) Replace every direct --ctp-* reference in component/layout/track CSS and TSX inline styles with the readable semantic token (--bg-app, --accent, --highlight, --text-on-accent, …). --ctp-* now survives only as the Catppuccin palette layer the base maps from, and as the deliberate categorical rainbow in Composers/Genres/artistsHelpers (left untouched). This is the readable contract surface for the community theme store. Divergences (theme set a semantic var != its --ctp- source) are corrections — the element now uses the theme's real semantic colour. * feat(themes): add player-bar title/artist color tokens New optional --player-title / --player-artist, defaulting to --text-primary / --text-secondary so nothing changes unless a theme overrides them. Lets a theme give the now-playing readout its own colour as a plain token. * refactor(themes): token-only theme library (flatten, whitelist, one file per theme) Turn every built-in theme into a single self-contained [data-theme] var block of semantic whitelist tokens (plus the internal --ctp-* palette layer): - Flatten all themes: drop structural override rules, @keyframes, and global-token overrides (radius / shadow-elevation / transition / spacing / font / focus-ring). Signature player-bar readout colours are preserved via the new --player-title / --player-artist tokens. - Normalize the var blocks to the semantic whitelist: drop the alternate token vocabulary (nav-active / scrollbar / bg-input / success / border-default / ...); rename --success -> --positive and --border-default -> --border where no whitelist equivalent was set. Migrate the few components that read those tokens to the whitelist equivalents. - Split multi-theme files so each theme ships as its own file, making the built-in set 1:1 with the per-theme store packaging. Kept as-is (built-in, not flattened): the two colour-blind-safe accessibility themes, plus the two curated core skins. * chore(themes): remove seven themes retired after the token refactor These themes leaned on heavy structural overrides and were dropped rather than flattened. Full removal each: the CSS file(s), the index.css import, the Theme type union, and the ThemePicker entry. * feat(themes): granular tokens — track lists Wire track rows to per-region tokens: row hover (--row-hover), the now-playing row + indicator (--row-playing-bg / --row-playing-text), track title/artist/ number/duration text, column-header text, row dividers, and the resize-handle active colour. Covers the desktop tracklist, the shared song-row (Tracks hub / search), and the mobile tracklist. Drop a baked border fallback. Visual no-op. * feat(themes): granular tokens — cards Wire album and artist cards to per-region tokens (--card-hover-border, --card-title, --card-subtitle, --card-placeholder-bg). Visual no-op. * fix(themes): drop undefined/baked colour aliases Replace the undefined --bg-surface (resolved to nothing — broken placeholder backgrounds and filter input) with --card-placeholder-bg / --input-bg, and the baked-hex aliases --color-error / --color-warning with --danger / --warning so themes can actually recolour them. * feat(themes): Spectrum demo theme + trim unused cascade tokens Add a loud built-in demo theme that gives each region its own hue (sidebar green, player pink, lists cyan, cards gold, menus red, controls blue) so the per-region granularity is obvious when you switch to it. Drop two unused cascade tokens (--sidebar-text-active, --row-active-bg) and the unused on-media block (those media surfaces stay static by design). * style(themes): make Spectrum demo brutally loud Full-saturation neon per region (toxic green sidebar, magenta player, cyan lists, acid-yellow cards, blood-red menus, electric-blue controls, purple scrollbar) so the per-region separation is unmistakable. The earlier soft tints were too subtle to read. * feat(themes): name the granular demo theme Braindead * feat(themes): granular per-region tokens — cascade layer + sidebar Add an optional per-region token layer (semantic-cascade.css) so a theme can recolour individual regions — sidebar hover, player controls, list rows, menus, inputs, on-media surfaces — independently of the global tokens. Every token defaults to its base token (or a media-safe literal), so this is a visual no-op until a theme overrides one; it only adds control points. Wire the sidebar region as the first consumer and drop the baked grey fallbacks (--bg-tertiary, etc.) that no theme could reach. * feat(themes): granular tokens — controls, menus, scrollbar Wire inputs, buttons, sliders, the custom-select, context menus, submenus and modals to per-region tokens, and tokenise the scrollbar. Complete B0 by dropping the last direct --ctp-* references in the input/button/progress/ scrollbar utility CSS. Fix three undefined-token bugs that fell back to nothing (so no theme could reach them): --surface-2 (context-menu hover had no highlight), --bg-surface (submenu create-input had no background), and the baked grey fallbacks in the custom-select. Every other new token defaults to today's value — a visual no-op that only adds override points. * feat(themes): granular tokens — player bar Wire the desktop player bar's transport controls, time toggle, and overflow menu to per-region tokens (--player-control, --player-time-toggle-*, etc.). Fix the undefined --surface-hover/--surface-active grey fallbacks on the time toggle. Visual no-op; defaults match today's values. * chore(themes): remove empty theme stub files Six theme CSS files were reduced to comment-only stubs by the flatten sweep but their files and @import lines remained. Five are empty structural companions of now-flattened themes (morpheus, p-dvd, aero-glass, luna-teal) and two are orphans of cut themes (order-of-the-phoenix, pandora). Removed the files and their imports. * feat(themes): runtime injection foundation for the theme store Plumbing for installed community themes ahead of the in-app store UI, nothing user-visible yet: - installedThemesStore: persisted (localStorage) record of installed community themes incl. their CSS text, so an active community theme is available synchronously at startup (no flash, fully offline). - themeInjection: reconcile <head> <style data-installed-theme> elements with the store; lightweight defense-in-depth sanitize on top of CI. - themeRegistry: jsDelivr registry client with a 12h localStorage cache and stale-on-error fallback. - App: inject installed themes before applying data-theme, in both webviews. - themeStore: widen the Theme type to accept dynamic installed ids. * feat(themes): dedicated Themes settings tab Move theme selection and the day/night scheduler out of Appearance into a new dedicated Themes tab — the future home of the community Theme Store. Appearance keeps grid columns, visual options, UI scale, font and seekbar. - ThemesTab: theme picker + scheduler (relocated verbatim). - AppearanceTab: drop the two relocated sections + now-unused imports. - Register the tab in settingsTabs (Tab union, resolveTab, search index) and Settings (tab bar, render, label map). - i18n: settings.tabThemes in all 9 locales. * feat(themes): community Theme Store browse + install Add the Theme Store section to the Themes tab: - Fetch the jsDelivr registry (12h cache, stale-on-error fallback). - Search by name/author/description + filter by light/dark + refresh. - Per-row CDN thumbnail, name, author, description and actions: Install / Apply / Update / Uninstall. Installing fetches the CSS, persists it (localStorage) and the runtime injection applies it; uninstalling the active theme falls back to the matching core. - Rating slot left reserved (deferred). - i18n: themeStore* keys in all 9 locales. * feat(themes): slim bundle to fixed cores + flat Themes tab Remove the 86 store palettes (incl. braindead) from the app bundle — the CSS files, their index.css imports, the Theme union and the picker data — leaving only the six fixed cores (Catppuccin Mocha/Latte, Kanagawa Wave, Stark HUD, Vision Dark/Navy). Everything else installs from the store. Themes tab is rebuilt flat (no collapsible accordions): - "Your Themes": one card grid of the fixed cores + installed community themes; click to apply, uninstall on community ones (active theme falls back to the matching core). Catppuccin prefix on Mocha/Latte; a CVD-safe pill on the colour-blind-safe Vision themes. - Scheduler day/night options include installed themes. - Theme Store: alphabetical order, thumbnail lightbox, and a submit hint above the search linking to the themes repository. - Nav order: Servers, Library, Audio, Themes, Appearance, Lyrics, … - ThemePicker accordion removed; fixed-theme data moved to fixedThemes.ts. - i18n for all new strings across 9 locales. * feat(themes): reset removed-from-bundle themes to a bundled fallback After slimming the bundle to the six fixed core themes, a profile upgraded from an older build may have an active or scheduler theme that is now store-only and not installed — it has no [data-theme] block and would render as unstyled :root. Reset any theme/themeDay/themeNight that is neither bundled nor installed to a bundled fallback: Mocha for the main + night slots, Latte for the day slot. Runs synchronously in runPreReactBootstrap, rewriting the persisted selection in localStorage before React mounts (no flash; Zustand rehydrates after first paint). No auto-install and no network — the fallback is always a bundled theme, so it works offline. * feat(themes): floating back-to-top button on the Themes tab The Themes tab can get long (theme grid + scheduler + full store list), so add a floating back-to-top affordance that appears once the page is scrolled and smooth-scrolls to the top. It is portalled into the route host and positioned absolute against it — the main scroll viewport sets contain: paint, which would otherwise make position: fixed resolve against the scrolling box and drift with the content. Reusable component (scroll viewport id + threshold props); i18n common.backToTop added in all nine locales. * feat(themes): accessibility + state polish for the Theme Store - Reuse the shared CoverLightbox for the thumbnail preview instead of a second inline dialog — gains a visible close button and a focus-managed, portalled dialog, and drops duplicated markup. - Theme cards expose aria-pressed so assistive tech announces the selected theme, not just the visual check. - Transient store messages get live-region roles (loading/empty/install failure = status, fetch error = alert). - Thumbnails degrade gracefully when offline/missing (hide the broken-image glyph; the thumbnail button no longer stretches with the row, so its background can't show as letterbox bars). * feat(themes): larger store-row thumbnails (120x75 -> 200x125) The list previews were too small to make a theme out; bump the display size (same 1.6 aspect). Thumbnails are now served at 720x450, so the larger display stays crisp. * fix(themes): bust thumbnail cache on registry change jsDelivr serves theme thumbnails with a 7-day max-age, so when a thumbnail is updated the webview keeps showing its cached old image (the path is unchanged). Append the registry's generatedAt as a cache-busting query to the thumbnail URLs (list + lightbox); it changes on every themes push, so a registry refresh makes the webview re-fetch and reflect the current CDN image instead of a stale one. * docs(themes): changelog + credits for the Theme Store Add the 1.48.0 "Themes — community Theme Store" changelog entry (PR #1009) and the matching line in the Psychotoxical credits. * fix(themes): address PR review (uninstall hygiene, validation, polish) Uninstall/scheduler & validation: - uninstallTheme() repairs every selection slot (active + day + night), not just the manual one, and is shared by both uninstall buttons (dedup). - Validate theme CSS at install time and skip persisting CSS that won't inject (no more "installed/active but renders nothing" with no feedback). - Harden the runtime validator: exactly one rule, scoped exactly to the theme's [data-theme='<id>'] selector (no unscoped/foreign selectors), no at-rules, url() only data:, no expression()/javascript:, size-capped. Tokens & polish: - Fix three dangling undefined tokens (--surface-2 x2, --bg-surface). - Finish the warning/success token sweep (--warning / --positive, themeable). - Apply the active theme synchronously before React mounts (no first-frame flash) and inject installed themes up front. - One-time, dismissible notice when the slim-bundle migration reset a theme. - Update badge uses semver, not string inequality. - Offline/stale indicator in the store; cross-window theme sync; drop the now dead REMOVED_THEME_REMAP and Card.mode field. Tests: themeInjection (validator + sync), themeRegistry (cache/force/stale/ malformed), uninstallTheme (slot repair), migration notice. i18n in all nine locales. Full suite green (1755 tests). * test(bootstrap): cover startup theme apply + cross-window sync The review fixes added applyThemeAtStartup / installCrossWindowThemeSync to bootstrap.ts (a hot-path file) without tests, dropping its coverage to 68.3% and failing the frontend hot-path coverage gate (>=70%). Add unit tests for both (and the no-op / malformed-storage paths); bootstrap.ts is back to ~98%.
This commit is contained in:
@@ -27,8 +27,8 @@ export function renderStars(rating?: number) {
|
||||
<Star
|
||||
key={i}
|
||||
size={12}
|
||||
fill={i <= rating ? 'var(--ctp-yellow)' : 'none'}
|
||||
color={i <= rating ? 'var(--ctp-yellow)' : 'var(--text-muted)'}
|
||||
fill={i <= rating ? 'var(--highlight)' : 'none'}
|
||||
color={i <= rating ? 'var(--highlight)' : 'var(--text-muted)'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ function getCachedLogoSvg(color: string): string {
|
||||
const raw = renderToStaticMarkup(React.createElement(PsysonicLogo, { gradientIdSuffix: 'export' }));
|
||||
const swapped = raw
|
||||
.replace('var(--logo-color-start, var(--accent))', color)
|
||||
.replace('var(--logo-color-end, var(--ctp-blue))', color);
|
||||
.replace('var(--logo-color-end, var(--accent-2))', color);
|
||||
cachedLogoSvg = swapped;
|
||||
cachedLogoSvgKey = color;
|
||||
return swapped;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Tests for the runtime theme-CSS trust boundary and <head> injection sync.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
validateThemeCss,
|
||||
injectTheme,
|
||||
syncInjectedThemes,
|
||||
} from './themeInjection';
|
||||
import type { InstalledTheme } from '../../store/installedThemesStore';
|
||||
|
||||
const ATTR = 'data-installed-theme';
|
||||
|
||||
function mk(id: string, css: string): InstalledTheme {
|
||||
return { id, name: id, author: 'a', version: '1.0.0', description: '', mode: 'dark', css, installedAt: 0 };
|
||||
}
|
||||
const block = (id: string, body = '--accent:#fff;') => `[data-theme='${id}']{ ${body} }`;
|
||||
const injected = () => document.head.querySelectorAll(`style[${ATTR}]`);
|
||||
|
||||
afterEach(() => {
|
||||
document.head.querySelectorAll(`style[${ATTR}]`).forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
describe('validateThemeCss', () => {
|
||||
it('accepts a valid single scoped rule', () => {
|
||||
expect(validateThemeCss(block('dracula'), 'dracula')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a data: url on the contract (e.g. --select-arrow)', () => {
|
||||
const css = block('x', `--select-arrow: url("data:image/svg+xml,%3Csvg%3E%3C/svg%3E");`);
|
||||
expect(validateThemeCss(css, 'x')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects @import', () => {
|
||||
expect(validateThemeCss(`@import 'evil.css'; ${block('x')}`, 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a non-data url()', () => {
|
||||
expect(validateThemeCss(block('x', `--accent: url(https://evil.test/x.png);`), 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects </style> breakout', () => {
|
||||
expect(validateThemeCss(`${block('x')}</style><script>`, 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an unscoped/global selector', () => {
|
||||
expect(validateThemeCss(':root{ --accent:red; }', 'x')).toBeNull();
|
||||
expect(validateThemeCss('*{ color:red; }', 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a foreign theme id selector', () => {
|
||||
expect(validateThemeCss(block('other'), 'dracula')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects more than one rule', () => {
|
||||
expect(validateThemeCss(`${block('x')} ${block('x', '--bg-app:#000;')}`, 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects expression() / javascript:', () => {
|
||||
expect(validateThemeCss(block('x', '--accent: expression(alert(1));'), 'x')).toBeNull();
|
||||
expect(validateThemeCss(block('x', '--accent: javascript:alert(1);'), 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an oversized css blob', () => {
|
||||
const huge = `[data-theme='x']{ ${'--accent:#ffffff;'.repeat(6000)} }`;
|
||||
expect(huge.length).toBeGreaterThan(64 * 1024);
|
||||
expect(validateThemeCss(huge, 'x')).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores comments when validating', () => {
|
||||
expect(validateThemeCss(`/* hi */ ${block('x')}`, 'x')).not.toBeNull();
|
||||
// comment cannot smuggle a second rule past the single-rule shape
|
||||
expect(validateThemeCss(`${block('x')} /* */ ${block('x')}`, 'x')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncInjectedThemes', () => {
|
||||
it('injects one <style> per installed theme', () => {
|
||||
syncInjectedThemes([mk('a', block('a')), mk('b', block('b'))]);
|
||||
expect(injected()).toHaveLength(2);
|
||||
expect(document.head.querySelector(`style[${ATTR}="a"]`)?.textContent).toContain('data-theme');
|
||||
});
|
||||
|
||||
it('removes styles for themes no longer installed', () => {
|
||||
syncInjectedThemes([mk('a', block('a')), mk('b', block('b'))]);
|
||||
syncInjectedThemes([mk('a', block('a'))]);
|
||||
expect(injected()).toHaveLength(1);
|
||||
expect(document.head.querySelector(`style[${ATTR}="b"]`)).toBeNull();
|
||||
});
|
||||
|
||||
it('is idempotent (no duplicate elements)', () => {
|
||||
syncInjectedThemes([mk('a', block('a'))]);
|
||||
syncInjectedThemes([mk('a', block('a'))]);
|
||||
expect(injected()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('updates textContent when the css changes', () => {
|
||||
injectTheme(mk('a', block('a', '--accent:#111;')));
|
||||
injectTheme(mk('a', block('a', '--accent:#222;')));
|
||||
const el = document.head.querySelector(`style[${ATTR}="a"]`);
|
||||
expect(injected()).toHaveLength(1);
|
||||
expect(el?.textContent).toContain('#222');
|
||||
});
|
||||
|
||||
it('does not inject invalid css', () => {
|
||||
syncInjectedThemes([mk('a', ':root{ --accent:red; }')]);
|
||||
expect(injected()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { InstalledTheme } from '../../store/installedThemesStore';
|
||||
|
||||
/**
|
||||
* Runtime CSS injection for installed community themes. Built-in themes are
|
||||
* bundled at build time (`src/styles/themes/index.css`); installed ones have no
|
||||
* build-time presence, so their `[data-theme='<id>']` block must be injected
|
||||
* into <head> at runtime. Each installed theme gets one
|
||||
* `<style data-installed-theme="<id>">` element, kept in sync with the store.
|
||||
*/
|
||||
|
||||
const ATTR = 'data-installed-theme';
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one installed theme's CSS against the runtime trust boundary. The
|
||||
* repo CI is the authoritative contract check (token whitelist, thumbnail, …);
|
||||
* this is the in-app guard for the structural invariants the app *relies on* to
|
||||
* keep a theme contained — important because every installed theme is injected
|
||||
* into <head> at all times and scoping depends entirely on the selector.
|
||||
*
|
||||
* A valid theme CSS is **exactly one rule**, scoped **exactly** to this theme's
|
||||
* `[data-theme='<id>']` selector, with:
|
||||
* - no `<style>`/`</style>` (can't break out of the element),
|
||||
* - no `@import` / at-rules (the single-rule shape rejects these),
|
||||
* - no unscoped/global (`:root`, `html`, `*`) or *foreign* (another id's)
|
||||
* selector — so it can never style anything but its own theme,
|
||||
* - `url()` only as `data:` (the inline `--select-arrow` SVG), and
|
||||
* - no `expression()` / `javascript:`.
|
||||
*
|
||||
* Returns the original CSS if valid, or `null` if it must not be injected.
|
||||
*/
|
||||
export function validateThemeCss(css: string, id: string): string | null {
|
||||
if (typeof css !== 'string' || !css) return null;
|
||||
// Bound the per-theme localStorage footprint. A real token-only theme is a
|
||||
// few KB; this generous cap rejects a pathological/huge file before it can
|
||||
// eat the install store's quota.
|
||||
if (css.length > 64 * 1024) return null;
|
||||
// Strip comments first so they can't smuggle content past the checks.
|
||||
const stripped = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
|
||||
if (/<\/?\s*style/i.test(stripped)) return null;
|
||||
if (/@import/i.test(stripped)) return null;
|
||||
// Exactly one rule, scoped to exactly this theme's selector. The single
|
||||
// braces-pair shape also rejects at-rules (`@media{…}`), extra rules, and
|
||||
// unscoped/foreign selectors.
|
||||
const selector = `\\[data-theme=(['"])${escapeRegExp(id)}\\1\\]`;
|
||||
const match = stripped.match(new RegExp(`^${selector}\\s*\\{([^{}]*)\\}$`));
|
||||
if (!match) return null;
|
||||
const body = match[2]; // group 1 is the selector quote; group 2 is the declarations
|
||||
if (/expression\s*\(/i.test(body) || /javascript:/i.test(body)) return null;
|
||||
const urls = body.match(/url\(\s*['"]?[^'")]*/gi) || [];
|
||||
for (const u of urls) {
|
||||
const inner = u.replace(/^url\(\s*['"]?/i, '');
|
||||
if (!/^data:/i.test(inner)) return null;
|
||||
}
|
||||
return css;
|
||||
}
|
||||
|
||||
export function injectTheme(theme: InstalledTheme): void {
|
||||
const clean = validateThemeCss(theme.css, theme.id);
|
||||
if (clean == null) return;
|
||||
const selector = `style[${ATTR}="${CSS.escape(theme.id)}"]`;
|
||||
let el = document.head.querySelector<HTMLStyleElement>(selector);
|
||||
if (!el) {
|
||||
el = document.createElement('style');
|
||||
el.setAttribute(ATTR, theme.id);
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
if (el.textContent !== clean) el.textContent = clean;
|
||||
}
|
||||
|
||||
export function removeInjectedTheme(id: string): void {
|
||||
document.head.querySelector(`style[${ATTR}="${CSS.escape(id)}"]`)?.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the injected <style> elements with the given installed set: drop
|
||||
* styles for themes no longer installed, add/update the rest. Idempotent —
|
||||
* safe to call on every change and at startup.
|
||||
*/
|
||||
export function syncInjectedThemes(themes: InstalledTheme[]): void {
|
||||
const wanted = new Set(themes.map((t) => t.id));
|
||||
document.head
|
||||
.querySelectorAll<HTMLStyleElement>(`style[${ATTR}]`)
|
||||
.forEach((el) => {
|
||||
const id = el.getAttribute(ATTR);
|
||||
if (id && !wanted.has(id)) el.remove();
|
||||
});
|
||||
for (const theme of themes) injectTheme(theme);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Tests for the slim-bundle theme migration (C5).
|
||||
*
|
||||
* The migration rewrites the persisted theme selection in localStorage before
|
||||
* React mounts: any theme/themeDay/themeNight that is neither bundled
|
||||
* (FIXED_THEMES) nor installed is reset to a bundled fallback — Mocha for the
|
||||
* main + night slots, Latte for the day slot. It never installs anything and
|
||||
* must never throw on malformed/missing storage.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
migrateThemeSelection,
|
||||
readThemeMigrationNotice,
|
||||
clearThemeMigrationNotice,
|
||||
} from './themeMigration';
|
||||
import { FIXED_THEMES } from '../../components/settings/fixedThemes';
|
||||
|
||||
const THEME_KEY = 'psysonic_theme';
|
||||
const INSTALLED_KEY = 'psysonic_installed_themes';
|
||||
|
||||
interface ThemeSlots {
|
||||
theme?: unknown;
|
||||
themeDay?: unknown;
|
||||
themeNight?: unknown;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
function setPersistedTheme(state: ThemeSlots, version = 1): void {
|
||||
localStorage.setItem(THEME_KEY, JSON.stringify({ state, version }));
|
||||
}
|
||||
|
||||
function readPersistedTheme(): { state: ThemeSlots; version: number } {
|
||||
return JSON.parse(localStorage.getItem(THEME_KEY) as string);
|
||||
}
|
||||
|
||||
function setInstalled(ids: string[]): void {
|
||||
localStorage.setItem(
|
||||
INSTALLED_KEY,
|
||||
JSON.stringify({ state: { themes: ids.map((id) => ({ id })) }, version: 1 }),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('migrateThemeSelection', () => {
|
||||
it('does nothing when there is no persisted theme (fresh profile)', () => {
|
||||
migrateThemeSelection();
|
||||
expect(localStorage.getItem(THEME_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves bundled (fixed) themes untouched', () => {
|
||||
setPersistedTheme({ theme: 'kanagawa-wave', themeDay: 'latte', themeNight: 'mocha' });
|
||||
migrateThemeSelection();
|
||||
expect(readPersistedTheme().state).toMatchObject({
|
||||
theme: 'kanagawa-wave',
|
||||
themeDay: 'latte',
|
||||
themeNight: 'mocha',
|
||||
});
|
||||
});
|
||||
|
||||
it('every fixed theme id resolves (none is treated as unresolved)', () => {
|
||||
const ids = FIXED_THEMES.map((t) => t.id);
|
||||
setPersistedTheme({ theme: ids[0], themeDay: ids[1], themeNight: ids[2] });
|
||||
migrateThemeSelection();
|
||||
expect(readPersistedTheme().state).toMatchObject({
|
||||
theme: ids[0],
|
||||
themeDay: ids[1],
|
||||
themeNight: ids[2],
|
||||
});
|
||||
});
|
||||
|
||||
it('resets an unresolved main theme to Mocha (dark)', () => {
|
||||
setPersistedTheme({ theme: 'dracula', themeDay: 'latte', themeNight: 'mocha' });
|
||||
migrateThemeSelection();
|
||||
expect(readPersistedTheme().state.theme).toBe('mocha');
|
||||
});
|
||||
|
||||
it('resets an unresolved day theme to Latte (light)', () => {
|
||||
setPersistedTheme({ theme: 'mocha', themeDay: 'nord-snowstorm', themeNight: 'mocha' });
|
||||
migrateThemeSelection();
|
||||
expect(readPersistedTheme().state.themeDay).toBe('latte');
|
||||
});
|
||||
|
||||
it('resets an unresolved night theme to Mocha (dark)', () => {
|
||||
setPersistedTheme({ theme: 'mocha', themeDay: 'latte', themeNight: 'blade' });
|
||||
migrateThemeSelection();
|
||||
expect(readPersistedTheme().state.themeNight).toBe('mocha');
|
||||
});
|
||||
|
||||
it('keeps a store theme that the user has installed', () => {
|
||||
setInstalled(['dracula']);
|
||||
setPersistedTheme({ theme: 'dracula', themeDay: 'latte', themeNight: 'mocha' });
|
||||
migrateThemeSelection();
|
||||
expect(readPersistedTheme().state.theme).toBe('dracula');
|
||||
});
|
||||
|
||||
it('migrates only the unresolved slots and preserves the rest', () => {
|
||||
setInstalled(['gruvbox-dark-hard']);
|
||||
setPersistedTheme({
|
||||
theme: 'nord', // unresolved -> mocha
|
||||
themeDay: 'latte', // fixed -> unchanged
|
||||
themeNight: 'gruvbox-dark-hard', // installed -> unchanged
|
||||
timeDayStart: '07:00',
|
||||
enableThemeScheduler: true,
|
||||
});
|
||||
migrateThemeSelection();
|
||||
const { state, version } = readPersistedTheme();
|
||||
expect(state).toMatchObject({
|
||||
theme: 'mocha',
|
||||
themeDay: 'latte',
|
||||
themeNight: 'gruvbox-dark-hard',
|
||||
timeDayStart: '07:00',
|
||||
enableThemeScheduler: true,
|
||||
});
|
||||
expect(version).toBe(1);
|
||||
});
|
||||
|
||||
it('does not rewrite storage when nothing changes', () => {
|
||||
setPersistedTheme({ theme: 'mocha', themeDay: 'latte', themeNight: 'mocha' });
|
||||
const before = localStorage.getItem(THEME_KEY);
|
||||
migrateThemeSelection();
|
||||
expect(localStorage.getItem(THEME_KEY)).toBe(before);
|
||||
});
|
||||
|
||||
it('still falls back when the installed-themes store is malformed', () => {
|
||||
localStorage.setItem(INSTALLED_KEY, '{not valid json');
|
||||
setPersistedTheme({ theme: 'dracula', themeDay: 'latte', themeNight: 'mocha' });
|
||||
migrateThemeSelection();
|
||||
expect(readPersistedTheme().state.theme).toBe('mocha');
|
||||
});
|
||||
|
||||
it('does not throw on malformed theme storage', () => {
|
||||
localStorage.setItem(THEME_KEY, '{not valid json');
|
||||
expect(() => migrateThemeSelection()).not.toThrow();
|
||||
expect(localStorage.getItem(THEME_KEY)).toBe('{not valid json');
|
||||
});
|
||||
|
||||
it('ignores empty-string slot values', () => {
|
||||
setPersistedTheme({ theme: '', themeDay: 'latte', themeNight: 'mocha' });
|
||||
migrateThemeSelection();
|
||||
// empty string is not a real selection — left as-is, store applies its default
|
||||
expect(readPersistedTheme().state.theme).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme migration notice', () => {
|
||||
it('records the original id(s) that were reset', () => {
|
||||
setPersistedTheme({ theme: 'dracula', themeDay: 'latte', themeNight: 'nord' });
|
||||
migrateThemeSelection();
|
||||
expect(readThemeMigrationNotice().sort()).toEqual(['dracula', 'nord']);
|
||||
});
|
||||
|
||||
it('deduplicates ids used in multiple slots', () => {
|
||||
setPersistedTheme({ theme: 'dracula', themeDay: 'latte', themeNight: 'dracula' });
|
||||
migrateThemeSelection();
|
||||
expect(readThemeMigrationNotice()).toEqual(['dracula']);
|
||||
});
|
||||
|
||||
it('writes no notice when nothing was reset', () => {
|
||||
setPersistedTheme({ theme: 'mocha', themeDay: 'latte', themeNight: 'mocha' });
|
||||
migrateThemeSelection();
|
||||
expect(readThemeMigrationNotice()).toEqual([]);
|
||||
});
|
||||
|
||||
it('clear removes the notice', () => {
|
||||
setPersistedTheme({ theme: 'dracula', themeDay: 'latte', themeNight: 'mocha' });
|
||||
migrateThemeSelection();
|
||||
expect(readThemeMigrationNotice()).toEqual(['dracula']);
|
||||
clearThemeMigrationNotice();
|
||||
expect(readThemeMigrationNotice()).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] on malformed notice storage', () => {
|
||||
localStorage.setItem('psysonic_theme_migration_notice', '{not json');
|
||||
expect(readThemeMigrationNotice()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { FIXED_THEMES } from '../../components/settings/fixedThemes';
|
||||
|
||||
/**
|
||||
* Slim-bundle theme migration (C5). Older builds bundled ~80 palettes that now
|
||||
* live only in the community Theme Store. A persisted active / scheduler theme
|
||||
* may therefore reference an id that is neither bundled (`FIXED_THEMES`) nor
|
||||
* installed — it would resolve to no `[data-theme]` block and render as an
|
||||
* unstyled `:root`.
|
||||
*
|
||||
* This runs synchronously in `runPreReactBootstrap` *before* React mounts,
|
||||
* rewriting the persisted selection in localStorage so the store hydrates
|
||||
* already-correct (no flash of the wrong/blank theme — Zustand rehydrate runs
|
||||
* after first paint, so we read/write the persisted JSON directly, matching the
|
||||
* other pre-React bootstrap steps).
|
||||
*
|
||||
* By design we do **not** auto-install anything from the store: an unresolved
|
||||
* id simply falls back to a bundled theme — Mocha (dark) for the main + night
|
||||
* slots, Latte (light) for the day slot. The fallback is always bundled, so the
|
||||
* migration never needs the network and works offline.
|
||||
*/
|
||||
|
||||
const THEME_KEY = 'psysonic_theme';
|
||||
const INSTALLED_KEY = 'psysonic_installed_themes';
|
||||
/** Set when the migration reset a theme, so the app can show a one-time notice. */
|
||||
const NOTICE_KEY = 'psysonic_theme_migration_notice';
|
||||
const FALLBACK_DARK = 'mocha';
|
||||
const FALLBACK_LIGHT = 'latte';
|
||||
|
||||
const FIXED_IDS = new Set(FIXED_THEMES.map((t) => t.id));
|
||||
|
||||
/** Per-slot fallback: main + night are dark (Mocha), the day slot is light (Latte). */
|
||||
const SLOT_FALLBACK = {
|
||||
theme: FALLBACK_DARK,
|
||||
themeDay: FALLBACK_LIGHT,
|
||||
themeNight: FALLBACK_DARK,
|
||||
} as const;
|
||||
|
||||
/** Ids of themes the user has installed from the store (persisted CSS lives here). */
|
||||
function installedIds(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(INSTALLED_KEY);
|
||||
if (!raw) return new Set();
|
||||
const parsed = JSON.parse(raw) as { state?: { themes?: Array<{ id?: unknown }> } };
|
||||
const ids = (parsed.state?.themes ?? [])
|
||||
.map((t) => t.id)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
return new Set(ids);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateThemeSelection(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(THEME_KEY);
|
||||
if (!raw) return; // fresh profile — defaults are bundled themes already
|
||||
const parsed = JSON.parse(raw) as { state?: Record<string, unknown> };
|
||||
const state = parsed.state;
|
||||
if (!state || typeof state !== 'object') return;
|
||||
|
||||
const installed = installedIds();
|
||||
const isResolved = (id: unknown): boolean =>
|
||||
typeof id === 'string' && (FIXED_IDS.has(id) || installed.has(id));
|
||||
|
||||
let changed = false;
|
||||
const resetIds: string[] = [];
|
||||
for (const slot of ['theme', 'themeDay', 'themeNight'] as const) {
|
||||
const current = state[slot];
|
||||
if (typeof current === 'string' && current.length > 0 && !isResolved(current)) {
|
||||
resetIds.push(current);
|
||||
state[slot] = SLOT_FALLBACK[slot];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
localStorage.setItem(THEME_KEY, JSON.stringify(parsed));
|
||||
// Record what was reset so the app can show a one-time, dismissible notice.
|
||||
const unique = [...new Set(resetIds)];
|
||||
if (unique.length) localStorage.setItem(NOTICE_KEY, JSON.stringify(unique));
|
||||
}
|
||||
} catch {
|
||||
// Malformed storage or non-browser runtime — non-fatal; the store falls back
|
||||
// to its own bundled defaults on hydrate.
|
||||
}
|
||||
}
|
||||
|
||||
/** Ids of themes the migration reset (for the one-time notice); [] if none. */
|
||||
export function readThemeMigrationNotice(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(NOTICE_KEY);
|
||||
if (!raw) return [];
|
||||
const arr = JSON.parse(raw);
|
||||
return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the one-time migration notice. */
|
||||
export function clearThemeMigrationNotice(): void {
|
||||
try {
|
||||
localStorage.removeItem(NOTICE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Tests for the Theme Store registry client: TTL cache, force refresh,
|
||||
* stale-on-error fallback, and malformed-cache tolerance.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fetchRegistry, getCachedRegistry } from './themeRegistry';
|
||||
|
||||
const CACHE_KEY = 'psysonic_theme_registry_cache';
|
||||
const NOW = 1_000_000_000;
|
||||
const TTL = 12 * 60 * 60 * 1000;
|
||||
|
||||
const reg = (generatedAt = 't') => ({ schemaVersion: 1, generatedAt, themes: [] });
|
||||
const okRes = (body: unknown) => ({ ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) });
|
||||
const failRes = () => ({ ok: false, status: 500, json: async () => ({}), text: async () => '' });
|
||||
|
||||
function writeCache(ts: number, generatedAt: string): void {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts, registry: reg(generatedAt) }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(NOW);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('fetchRegistry', () => {
|
||||
it('fetches and caches when there is no cache', async () => {
|
||||
const fetchMock = vi.fn(async () => okRes(reg('fresh')));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const r = await fetchRegistry();
|
||||
expect(r.registry.generatedAt).toBe('fresh');
|
||||
expect(r.stale).toBe(false);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.getItem(CACHE_KEY)).toContain('fresh');
|
||||
});
|
||||
|
||||
it('returns a fresh cache without fetching', async () => {
|
||||
writeCache(NOW, 'cached');
|
||||
const fetchMock = vi.fn(async () => okRes(reg('network')));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const r = await fetchRegistry();
|
||||
expect(r.registry.generatedAt).toBe('cached');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('force-refreshes even with a fresh cache', async () => {
|
||||
writeCache(NOW, 'cached');
|
||||
const fetchMock = vi.fn(async () => okRes(reg('forced')));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const r = await fetchRegistry({ force: true });
|
||||
expect(r.registry.generatedAt).toBe('forced');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to a stale cache when the fetch fails', async () => {
|
||||
writeCache(NOW - TTL - 1, 'stale'); // older than TTL → not fresh
|
||||
vi.stubGlobal('fetch', vi.fn(async () => failRes()));
|
||||
|
||||
const r = await fetchRegistry();
|
||||
expect(r.registry.generatedAt).toBe('stale');
|
||||
expect(r.stale).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when the fetch fails and there is no cache', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => failRes()));
|
||||
await expect(fetchRegistry()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('treats a malformed cache as no cache and fetches', async () => {
|
||||
localStorage.setItem(CACHE_KEY, '{ not valid json');
|
||||
expect(getCachedRegistry()).toBeNull();
|
||||
const fetchMock = vi.fn(async () => okRes(reg('fresh2')));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const r = await fetchRegistry();
|
||||
expect(r.registry.generatedAt).toBe('fresh2');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Theme Store registry client. Reads the auto-generated `registry.json` from the
|
||||
* public `Psysonic/psysonic-themes` repo via the jsDelivr CDN (CORS-enabled,
|
||||
* globally cached). The registry is cached in localStorage with a TTL so the
|
||||
* store opens instantly and works offline against the last-seen catalogue.
|
||||
*/
|
||||
|
||||
const CDN_BASE = 'https://cdn.jsdelivr.net/gh/Psysonic/psysonic-themes@main';
|
||||
const REGISTRY_URL = `${CDN_BASE}/registry.json`;
|
||||
const CACHE_KEY = 'psysonic_theme_registry_cache';
|
||||
const TTL_MS = 12 * 60 * 60 * 1000; // 12h — matches jsDelivr's @main edge cache
|
||||
|
||||
export interface RegistryTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
author: string;
|
||||
version: string;
|
||||
description: string;
|
||||
mode: 'dark' | 'light';
|
||||
tags?: string[];
|
||||
/** Repo-relative path to the theme's CSS. */
|
||||
css: string;
|
||||
/** Repo-relative path to the thumbnail. */
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
export interface Registry {
|
||||
schemaVersion: number;
|
||||
generatedAt: string;
|
||||
themes: RegistryTheme[];
|
||||
}
|
||||
|
||||
interface CacheEnvelope {
|
||||
ts: number;
|
||||
registry: Registry;
|
||||
}
|
||||
|
||||
/** Absolute CDN URL for a repo-relative path (css / thumbnail). */
|
||||
export function cdnUrl(relPath: string): string {
|
||||
return `${CDN_BASE}/${relPath}`;
|
||||
}
|
||||
|
||||
function readCache(): CacheEnvelope | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
const env = JSON.parse(raw) as CacheEnvelope;
|
||||
if (!env || typeof env.ts !== 'number' || !env.registry) return null;
|
||||
return env;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(registry: Registry): void {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), registry }));
|
||||
} catch {
|
||||
// Quota or serialization failure is non-fatal — we just re-fetch next time.
|
||||
}
|
||||
}
|
||||
|
||||
/** Last-seen registry regardless of age (for offline use). */
|
||||
export function getCachedRegistry(): Registry | null {
|
||||
return readCache()?.registry ?? null;
|
||||
}
|
||||
|
||||
export interface FetchRegistryResult {
|
||||
registry: Registry;
|
||||
/** True when the network fetch failed and we served a cached copy instead. */
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the registry. Returns the cached copy if it is still fresh, unless
|
||||
* `force` is set (manual refresh). Falls back to a cached copy if the network
|
||||
* fetch fails (flagged `stale: true`) so the store keeps working offline.
|
||||
*/
|
||||
export async function fetchRegistry(opts?: { force?: boolean }): Promise<FetchRegistryResult> {
|
||||
if (!opts?.force) {
|
||||
const cached = readCache();
|
||||
if (cached && Date.now() - cached.ts < TTL_MS) return { registry: cached.registry, stale: false };
|
||||
}
|
||||
try {
|
||||
const res = await fetch(REGISTRY_URL, { cache: 'no-cache' });
|
||||
if (!res.ok) throw new Error(`registry fetch failed: ${res.status}`);
|
||||
const registry = (await res.json()) as Registry;
|
||||
writeCache(registry);
|
||||
return { registry, stale: false };
|
||||
} catch (err) {
|
||||
const cached = readCache();
|
||||
if (cached) return { registry: cached.registry, stale: true };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch a single theme's CSS text from the CDN (repo-relative path). */
|
||||
export async function fetchThemeCss(relPath: string): Promise<string> {
|
||||
const res = await fetch(cdnUrl(relPath), { cache: 'no-cache' });
|
||||
if (!res.ok) throw new Error(`theme css fetch failed: ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Tests for uninstallTheme — removing a community theme must repair every
|
||||
* selection slot that referenced it (active + scheduler day/night).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { uninstallTheme } from './uninstallTheme';
|
||||
import { useInstalledThemesStore, type InstalledTheme } from '../../store/installedThemesStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
|
||||
function mk(id: string, mode: 'dark' | 'light' = 'dark'): InstalledTheme {
|
||||
return { id, name: id, author: 'a', version: '1.0.0', description: '', mode, css: `[data-theme='${id}']{--accent:#fff;}`, installedAt: 0 };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useInstalledThemesStore.setState({ themes: [] });
|
||||
useThemeStore.setState({ theme: 'mocha', themeDay: 'latte', themeNight: 'mocha' });
|
||||
});
|
||||
|
||||
describe('uninstallTheme', () => {
|
||||
it('removes the theme from the installed store', () => {
|
||||
useInstalledThemesStore.getState().install(mk('dracula'));
|
||||
uninstallTheme('dracula');
|
||||
expect(useInstalledThemesStore.getState().isInstalled('dracula')).toBe(false);
|
||||
});
|
||||
|
||||
it('resets an active dark theme to mocha', () => {
|
||||
useInstalledThemesStore.getState().install(mk('dracula', 'dark'));
|
||||
useThemeStore.setState({ theme: 'dracula' });
|
||||
uninstallTheme('dracula');
|
||||
expect(useThemeStore.getState().theme).toBe('mocha');
|
||||
});
|
||||
|
||||
it('resets an active light theme to latte', () => {
|
||||
useInstalledThemesStore.getState().install(mk('nord-snowstorm', 'light'));
|
||||
useThemeStore.setState({ theme: 'nord-snowstorm' });
|
||||
uninstallTheme('nord-snowstorm');
|
||||
expect(useThemeStore.getState().theme).toBe('latte');
|
||||
});
|
||||
|
||||
it('resets the scheduler day slot to latte', () => {
|
||||
useInstalledThemesStore.getState().install(mk('dracula'));
|
||||
useThemeStore.setState({ themeDay: 'dracula' });
|
||||
uninstallTheme('dracula');
|
||||
expect(useThemeStore.getState().themeDay).toBe('latte');
|
||||
});
|
||||
|
||||
it('resets the scheduler night slot to mocha', () => {
|
||||
useInstalledThemesStore.getState().install(mk('dracula'));
|
||||
useThemeStore.setState({ themeNight: 'dracula' });
|
||||
uninstallTheme('dracula');
|
||||
expect(useThemeStore.getState().themeNight).toBe('mocha');
|
||||
});
|
||||
|
||||
it('repairs every slot at once', () => {
|
||||
useInstalledThemesStore.getState().install(mk('dracula', 'dark'));
|
||||
useThemeStore.setState({ theme: 'dracula', themeDay: 'dracula', themeNight: 'dracula' });
|
||||
uninstallTheme('dracula');
|
||||
const s = useThemeStore.getState();
|
||||
expect([s.theme, s.themeDay, s.themeNight]).toEqual(['mocha', 'latte', 'mocha']);
|
||||
});
|
||||
|
||||
it('leaves unrelated slots untouched', () => {
|
||||
useInstalledThemesStore.getState().install(mk('dracula'));
|
||||
useThemeStore.setState({ theme: 'kanagawa-wave', themeDay: 'latte', themeNight: 'mocha' });
|
||||
uninstallTheme('dracula');
|
||||
const s = useThemeStore.getState();
|
||||
expect([s.theme, s.themeDay, s.themeNight]).toEqual(['kanagawa-wave', 'latte', 'mocha']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useInstalledThemesStore } from '../../store/installedThemesStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
|
||||
/**
|
||||
* Uninstall a community theme and repair any theme selection that pointed at it.
|
||||
*
|
||||
* A removed theme has no `[data-theme]` block, so every slot referencing its id
|
||||
* must fall back to a bundled core — not just the manual `theme`, but the
|
||||
* scheduler's `themeDay` / `themeNight` too. Without this, uninstalling a theme
|
||||
* that is set as the day/night theme leaves the scheduler pointing at a missing
|
||||
* id; when the clock crosses into that slot the app renders unstyled until the
|
||||
* next launch (where the bootstrap migration would repair it).
|
||||
*
|
||||
* Fallback per slot: the active theme keeps its light/dark mode (Latte / Mocha);
|
||||
* the day slot is light (Latte), the night slot is dark (Mocha).
|
||||
*/
|
||||
export function uninstallTheme(id: string): void {
|
||||
const installed = useInstalledThemesStore.getState();
|
||||
const wasLight = installed.getInstalled(id)?.mode === 'light';
|
||||
installed.uninstall(id);
|
||||
|
||||
const t = useThemeStore.getState();
|
||||
if (t.theme === id) t.setTheme(wasLight ? 'latte' : 'mocha');
|
||||
if (t.themeDay === id) t.setThemeDay('latte');
|
||||
if (t.themeNight === id) t.setThemeNight('mocha');
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export function showToast(text: string, durationMs = 4000, variant: ToastVariant
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${isError ? 'var(--danger)' : isWarning ? 'var(--warning, #f59e0b)' : isSuccess ? 'var(--success, #10b981)' : 'var(--accent)'};
|
||||
background: ${isError ? 'var(--danger)' : isWarning ? 'var(--warning, #f59e0b)' : isSuccess ? 'var(--positive, #10b981)' : 'var(--accent)'};
|
||||
color: var(--bg-app);
|
||||
line-height: 1;
|
||||
`;
|
||||
@@ -54,7 +54,7 @@ export function showToast(text: string, durationMs = 4000, variant: ToastVariant
|
||||
const getBorderColor = () => {
|
||||
if (isError) return 'var(--danger)';
|
||||
if (isWarning) return 'var(--warning, #f59e0b)';
|
||||
if (isSuccess) return 'var(--success, #10b981)';
|
||||
if (isSuccess) return 'var(--positive, #10b981)';
|
||||
return 'var(--accent)';
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user