feat(ui): themed startup splash with deferred window show (#1030)

* feat(ui): add themed startup splash with deferred window show

Show a theme-aware loading splash before the Vite bundle mounts, hide the
native window until it paints, and use per-theme logo gradient colors.

* docs: note startup splash in CHANGELOG and credits for PR #1030

* docs: attribute PR #1030 startup splash to cucadmuh
This commit is contained in:
cucadmuh
2026-06-08 12:59:07 +03:00
committed by GitHub
parent 086c7e43b4
commit 5167d8f49e
14 changed files with 712 additions and 1 deletions
+10
View File
@@ -78,6 +78,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Startup — themed loading splash before the app bundle loads
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1030](https://github.com/Psychotoxical/psysonic/pull/1030)**
* Inline splash in `index.html` (progress bar + P logo) shows while the Vite bundle loads in dev and production — no empty or black window on launch.
* Splash colours follow the persisted theme (built-in palettes, day/night scheduler, and installed community themes); the logo uses each theme's accent gradient instead of a hardcoded white asset.
* The native window stays hidden until the splash has painted (`visible: false` + deferred `show` from Rust/JS); window-state restore no longer overrides startup visibility.
## Changed
### Dependencies — npm and Rust refresh
+82
View File
@@ -6,9 +6,91 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Psysonic Dein Navidrome Desktop Player" />
<title>Psysonic</title>
<script src="/startup-splash-preflight.js"></script>
<style>
html, body {
margin: 0;
min-height: 100%;
background: var(--startup-splash-bg, #1e1e2e);
}
#app-startup-splash {
--splash-bg: var(--startup-splash-bg, #1e1e2e);
--splash-text: var(--startup-splash-text, #cdd6f4);
--splash-muted: var(--startup-splash-muted, #a6adc8);
--splash-accent: var(--startup-splash-accent, #cba6f7);
--splash-track: var(--startup-splash-track, #313244);
--splash-logo-start: var(--startup-splash-logo-start, var(--splash-accent));
--splash-logo-end: var(--startup-splash-logo-end, var(--splash-accent));
position: fixed;
inset: 0;
z-index: 2147483646;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.75rem;
background: var(--splash-bg);
color: var(--splash-text);
font-family: system-ui, -apple-system, sans-serif;
opacity: 1;
transition: opacity 0.28s ease;
}
#app-startup-splash.app-startup-splash--hide {
opacity: 0;
pointer-events: none;
}
#app-startup-splash .app-startup-splash__logo {
height: 72px;
width: auto;
opacity: 0.95;
}
#app-startup-splash .app-startup-splash__label {
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--splash-muted);
}
#app-startup-splash .app-startup-splash__bar {
width: min(240px, 72vw);
height: 4px;
border-radius: 999px;
background: var(--splash-track);
overflow: hidden;
}
#app-startup-splash .app-startup-splash__bar-fill {
width: 42%;
height: 100%;
border-radius: inherit;
background: var(--splash-accent);
animation: app-startup-splash-indeterminate 1.15s ease-in-out infinite;
}
@keyframes app-startup-splash-indeterminate {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
</style>
</head>
<body>
<div id="app-startup-splash" role="status" aria-live="polite" aria-label="Loading Psysonic">
<svg class="app-startup-splash__logo" viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<linearGradient id="startupSplashLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="var(--splash-logo-start)" />
<stop offset="100%" stop-color="var(--splash-logo-end)" />
</linearGradient>
</defs>
<g transform="translate(220.53237,27.789086)">
<path fill="url(#startupSplashLogoGrad)" d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z" />
</g>
</svg>
<div class="app-startup-splash__bar" aria-hidden="true">
<div class="app-startup-splash__bar-fill"></div>
</div>
<span class="app-startup-splash__label">Loading</span>
</div>
<div id="root"></div>
<script src="/startup-splash-reveal.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+161
View File
@@ -0,0 +1,161 @@
/**
* Synchronous startup splash theme (before the Vite bundle loads).
* Keep palette ids/hex in sync with `src/config/startupSplashPalettes.ts`.
*/
(function startupSplashPreflight() {
var THEME_KEY = 'psysonic_theme';
var INSTALLED_KEY = 'psysonic_installed_themes';
var DEFAULT = {
bg: '#1e1e2e',
text: '#cdd6f4',
muted: '#a6adc8',
accent: '#cba6f7',
track: '#313244',
logoStart: '#cba6f7',
logoEnd: '#89b4fa',
};
var BUILTIN = {
mocha: DEFAULT,
latte: {
bg: '#eff1f5',
text: '#4c4f69',
muted: '#6c6f85',
accent: '#8839ef',
track: '#ccd0da',
logoStart: '#8839ef',
logoEnd: '#1e66f5',
},
'kanagawa-wave': {
bg: '#1F1F28',
text: '#DCD7BA',
muted: '#727169',
accent: '#7E9CD8',
track: '#2A2A37',
logoStart: '#7E9CD8',
logoEnd: '#957FB8',
},
'stark-hud': {
bg: '#0b0f15',
text: '#e0f7fa',
muted: '#7da5aa',
accent: '#00f2ff',
track: '#141b24',
logoStart: '#00f2ff',
logoEnd: '#7df9ff',
},
'vision-dark': {
bg: '#0d0b12',
text: '#f2eef8',
muted: '#a6a2b8',
accent: '#ffd700',
track: '#16131e',
logoStart: '#ffd700',
logoEnd: '#a07af8',
},
'vision-navy': {
bg: '#0a1628',
text: '#e8eef8',
muted: '#9caac2',
accent: '#ffd700',
track: '#12213a',
logoStart: '#ffd700',
logoEnd: '#a07af8',
},
};
function readCssVar(css, name) {
var match = css.match(new RegExp(name + '\\s*:\\s*([^;]+);'));
var value = match && match[1] ? match[1].trim() : '';
return value || null;
}
function resolveScheduledTheme(state) {
if (!state.enableThemeScheduler) return state.theme;
var now = new Date();
var nowMins = now.getHours() * 60 + now.getMinutes();
var dayParts = state.timeDayStart.split(':').map(Number);
var nightParts = state.timeNightStart.split(':').map(Number);
var dayMins = dayParts[0] * 60 + dayParts[1];
var nightMins = nightParts[0] * 60 + nightParts[1];
var isDay = dayMins < nightMins
? nowMins >= dayMins && nowMins < nightMins
: nowMins >= dayMins || nowMins < nightMins;
return isDay ? state.themeDay : state.themeNight;
}
function readThemeState() {
try {
var raw = localStorage.getItem(THEME_KEY);
if (!raw) return null;
var parsed = JSON.parse(raw);
var s = parsed && parsed.state;
if (!s) return null;
return {
enableThemeScheduler: !!s.enableThemeScheduler,
theme: String(s.theme || 'mocha'),
themeDay: String(s.themeDay || 'latte'),
themeNight: String(s.themeNight || 'mocha'),
timeDayStart: String(s.timeDayStart || '07:00'),
timeNightStart: String(s.timeNightStart || '19:00'),
};
} catch (_err) {
return null;
}
}
function readInstalledThemes() {
try {
var raw = localStorage.getItem(INSTALLED_KEY);
if (!raw) return [];
var parsed = JSON.parse(raw);
var themes = parsed && parsed.state && parsed.state.themes;
return Array.isArray(themes) ? themes : [];
} catch (_err) {
return [];
}
}
function paletteForTheme(themeId, installedThemes) {
if (BUILTIN[themeId]) return BUILTIN[themeId];
for (var i = 0; i < installedThemes.length; i += 1) {
var theme = installedThemes[i];
if (!theme || theme.id !== themeId || !theme.css) continue;
var bg = readCssVar(theme.css, '--bg-app');
var accent = readCssVar(theme.css, '--accent');
if (!bg || !accent) break;
var logoStart = readCssVar(theme.css, '--logo-color-start') || accent;
var logoEnd = readCssVar(theme.css, '--logo-color-end')
|| readCssVar(theme.css, '--accent-2')
|| accent;
return {
bg: bg,
text: readCssVar(theme.css, '--text-primary') || readCssVar(theme.css, '--ctp-text') || DEFAULT.text,
muted: readCssVar(theme.css, '--text-muted') || readCssVar(theme.css, '--ctp-subtext0') || DEFAULT.muted,
accent: accent,
track: readCssVar(theme.css, '--bg-card') || readCssVar(theme.css, '--border-subtle') || DEFAULT.track,
logoStart: logoStart,
logoEnd: logoEnd,
};
}
return DEFAULT;
}
function applyPalette(themeId, palette) {
var root = document.documentElement;
root.setAttribute('data-theme', themeId);
root.style.setProperty('--startup-splash-bg', palette.bg);
root.style.setProperty('--startup-splash-text', palette.text);
root.style.setProperty('--startup-splash-muted', palette.muted);
root.style.setProperty('--startup-splash-accent', palette.accent);
root.style.setProperty('--startup-splash-track', palette.track);
root.style.setProperty('--startup-splash-logo-start', palette.logoStart);
root.style.setProperty('--startup-splash-logo-end', palette.logoEnd);
root.style.background = palette.bg;
if (document.body) document.body.style.background = palette.bg;
}
var persisted = readThemeState();
var themeId = persisted ? resolveScheduledTheme(persisted) : 'mocha';
var palette = paletteForTheme(themeId, readInstalledThemes());
applyPalette(themeId, palette);
})();
+28
View File
@@ -0,0 +1,28 @@
/**
* Show the native window after the inline startup splash has painted.
* __TAURI_INTERNALS__ may not exist yet when this script first runs.
*/
(function startupSplashReveal() {
var MAX_ATTEMPTS = 60;
function tryShowMainWindow() {
var internals = window.__TAURI_INTERNALS__;
if (!internals || typeof internals.invoke !== 'function') return false;
internals.invoke('plugin:window|show', { label: 'main' }).catch(function () {});
return true;
}
function reveal(attempt) {
if (tryShowMainWindow()) return;
if (attempt >= MAX_ATTEMPTS) return;
window.setTimeout(function () {
reveal(attempt + 1);
}, 50);
}
requestAnimationFrame(function () {
requestAnimationFrame(function () {
reveal(0);
});
});
})();
+22
View File
@@ -97,6 +97,10 @@ pub fn run() {
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(
tauri_plugin_window_state::StateFlags::all()
& !tauri_plugin_window_state::StateFlags::VISIBLE,
)
.with_denylist(&["mini"])
.build()
)
@@ -625,6 +629,24 @@ pub fn run() {
}
}
})
.on_page_load(|webview, payload| {
if webview.label() != "main" {
return;
}
match payload.event() {
tauri::webview::PageLoadEvent::Started => {
let window = webview.window().clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(48));
let _ = window.show();
});
}
tauri::webview::PageLoadEvent::Finished => {
let _ = webview.window().show();
}
}
})
.invoke_handler(tauri::generate_handler![
greet,
theme_import::import_theme_zip,
+2 -1
View File
@@ -22,7 +22,8 @@
"fullscreen": false,
"decorations": true,
"transparent": false,
"visible": true,
"visible": false,
"backgroundColor": "#1e1e2e",
"dragDropEnabled": false
}
],
+2
View File
@@ -1,4 +1,5 @@
import { installQueueUndoHotkey } from '../store/queueUndoHotkey';
import { configureStartupSplash } from './startupSplash';
import { invoke } from '@tauri-apps/api/core';
import { getWindowKind } from './windowKind';
import { migrateThemeSelection } from '../utils/themes/themeMigration';
@@ -112,6 +113,7 @@ export function runPreReactBootstrap(): void {
migrateThemeSelection();
// Paint the correct theme on the very first frame (no Mocha flash).
applyThemeAtStartup();
configureStartupSplash();
installCrossWindowThemeSync();
markDevBuildDocument();
pushUserAgentToBackend();
+53
View File
@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
STARTUP_SPLASH_ID,
configureStartupSplash,
dismissStartupSplash,
} from './startupSplash';
vi.mock('./windowKind', () => ({
getWindowKind: vi.fn(() => 'main'),
}));
vi.mock('../utils/themes/startupThemeAppearance', () => ({
applyStartupSplashThemeFromStorage: vi.fn(() => 'mocha'),
}));
vi.mock('@tauri-apps/api/webviewWindow', () => ({
getCurrentWebviewWindow: vi.fn(() => ({ show: vi.fn(() => Promise.resolve()) })),
}));
import { getWindowKind } from './windowKind';
import { applyStartupSplashThemeFromStorage } from '../utils/themes/startupThemeAppearance';
describe('startupSplash', () => {
beforeEach(() => {
document.body.innerHTML = `<div id="${STARTUP_SPLASH_ID}"></div><div id="root"></div>`;
vi.mocked(getWindowKind).mockReturnValue('main');
vi.mocked(applyStartupSplashThemeFromStorage).mockClear();
});
afterEach(() => {
document.body.innerHTML = '';
});
it('removes splash on mini player webview', () => {
vi.mocked(getWindowKind).mockReturnValue('mini');
configureStartupSplash();
expect(document.getElementById(STARTUP_SPLASH_ID)).toBeNull();
});
it('re-applies theme from storage on main window', () => {
configureStartupSplash();
expect(applyStartupSplashThemeFromStorage).toHaveBeenCalled();
});
it('fades out and removes splash', () => {
vi.useFakeTimers();
dismissStartupSplash();
expect(document.getElementById(STARTUP_SPLASH_ID)?.classList.contains('app-startup-splash--hide')).toBe(true);
vi.runAllTimers();
expect(document.getElementById(STARTUP_SPLASH_ID)).toBeNull();
vi.useRealTimers();
});
});
+43
View File
@@ -0,0 +1,43 @@
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { applyStartupSplashThemeFromStorage } from '../utils/themes/startupThemeAppearance';
import { getWindowKind } from './windowKind';
export const STARTUP_SPLASH_ID = 'app-startup-splash';
/** Ensure the native shell is visible once the webview bundle is alive. */
export function revealStartupWindow(): void {
if (getWindowKind() === 'mini') return;
void getCurrentWebviewWindow().show().catch(() => {});
}
/** Re-apply splash colors after bootstrap theme migration/injection. */
export function configureStartupSplash(): void {
const splash = document.getElementById(STARTUP_SPLASH_ID);
if (!splash) return;
if (getWindowKind() === 'mini') {
splash.remove();
return;
}
applyStartupSplashThemeFromStorage();
revealStartupWindow();
}
/** Fade out the splash after the first React commit. */
export function dismissStartupSplash(): void {
const splash = document.getElementById(STARTUP_SPLASH_ID);
if (!splash || splash.classList.contains('app-startup-splash--hide')) return;
splash.classList.add('app-startup-splash--hide');
const remove = () => splash.remove();
splash.addEventListener('transitionend', remove, { once: true });
window.setTimeout(remove, 500);
}
/** Schedule dismiss on the frame after the first paint. */
export function scheduleStartupSplashDismiss(): void {
requestAnimationFrame(() => {
requestAnimationFrame(dismissStartupSplash);
});
}
+1
View File
@@ -154,6 +154,7 @@ const CONTRIBUTOR_ENTRIES = [
'Audio: Symphonia 0.6 migration with libopus adapter 0.3; ranged-stream start latency fix (probe seek-gate) and a probe timeout so a stalled stream no longer hangs playback start (PR #999)',
'Offline experience — unified media layout (cache/library/favorites), localPlaybackStore, library-index Offline Library, favorites auto-sync, cached album/playlist/artist pin reconcile, mixed-server offline queue, and single mediaDir setting (PR #1008)',
'Offline browse — local-bytes catalog when server is down, integration contract (context/policy/resolvers), disconnect nav fork, Home stale-cache feed, read-only context menus, PlayerBar rating/favorite guard (PR #1017)',
'Themed startup splash before Vite loads — deferred window show, per-theme logo gradient (PR #1030)',
],
},
{
+71
View File
@@ -0,0 +1,71 @@
/** Splash colors aligned with bundled theme semantic tokens (`--bg-app`, etc.). */
export type StartupSplashPalette = {
bg: string;
text: string;
muted: string;
accent: string;
track: string;
/** Sidebar logo gradient start (`--logo-color-start` / `--accent`). */
logoStart: string;
/** Sidebar logo gradient end (`--logo-color-end` / `--accent-2`). */
logoEnd: string;
};
export const BUILTIN_SPLASH_PALETTES: Record<string, StartupSplashPalette> = {
mocha: {
bg: '#1e1e2e',
text: '#cdd6f4',
muted: '#a6adc8',
accent: '#cba6f7',
track: '#313244',
logoStart: '#cba6f7',
logoEnd: '#89b4fa',
},
latte: {
bg: '#eff1f5',
text: '#4c4f69',
muted: '#6c6f85',
accent: '#8839ef',
track: '#ccd0da',
logoStart: '#8839ef',
logoEnd: '#1e66f5',
},
'kanagawa-wave': {
bg: '#1F1F28',
text: '#DCD7BA',
muted: '#727169',
accent: '#7E9CD8',
track: '#2A2A37',
logoStart: '#7E9CD8',
logoEnd: '#957FB8',
},
'stark-hud': {
bg: '#0b0f15',
text: '#e0f7fa',
muted: '#7da5aa',
accent: '#00f2ff',
track: '#141b24',
logoStart: '#00f2ff',
logoEnd: '#7df9ff',
},
'vision-dark': {
bg: '#0d0b12',
text: '#f2eef8',
muted: '#a6a2b8',
accent: '#ffd700',
track: '#16131e',
logoStart: '#ffd700',
logoEnd: '#a07af8',
},
'vision-navy': {
bg: '#0a1628',
text: '#e8eef8',
muted: '#9caac2',
accent: '#ffd700',
track: '#12213a',
logoStart: '#ffd700',
logoEnd: '#a07af8',
},
};
export const BUILTIN_THEME_IDS = Object.keys(BUILTIN_SPLASH_PALETTES);
+3
View File
@@ -2,6 +2,7 @@ import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { runPreReactBootstrap } from './app/bootstrap';
import { scheduleStartupSplashDismiss } from './app/startupSplash';
import './i18n';
import './styles/themes/index.css';
import './styles/layout/index.css';
@@ -15,3 +16,5 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<App />
</StrictMode>
);
scheduleStartupSplashDismiss();
@@ -0,0 +1,89 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { BUILTIN_SPLASH_PALETTES } from '../../config/startupSplashPalettes';
import {
applyStartupSplashPalette,
resolveEffectiveThemeId,
resolveScheduledThemeId,
resolveSplashPalette,
} from './startupThemeAppearance';
describe('startupThemeAppearance', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute('data-theme');
});
afterEach(() => {
localStorage.clear();
});
it('resolves scheduled day/night theme', () => {
const theme = resolveScheduledThemeId({
enableThemeScheduler: true,
theme: 'mocha',
themeDay: 'latte',
themeNight: 'stark-hud',
timeDayStart: '00:00',
timeNightStart: '12:00',
});
expect(['latte', 'stark-hud']).toContain(theme);
});
it('reads effective theme from persisted store', () => {
localStorage.setItem('psysonic_theme', JSON.stringify({
state: {
theme: 'vision-dark',
enableThemeScheduler: false,
},
}));
expect(resolveEffectiveThemeId()).toBe('vision-dark');
});
it('parses community theme css into splash palette', () => {
const palette = resolveSplashPalette('my-theme', [{
id: 'my-theme',
css: `[data-theme='my-theme'] { --bg-app: #112233; --accent: #aabbcc; --text-primary: #ddeeff; --text-muted: #99aabb; --bg-card: #223344; }`,
}]);
expect(palette).toMatchObject({
bg: '#112233',
accent: '#aabbcc',
text: '#ddeeff',
muted: '#99aabb',
track: '#223344',
logoStart: '#aabbcc',
logoEnd: '#aabbcc',
});
});
it('applies palette css variables on document root', () => {
applyStartupSplashPalette('kanagawa-wave', BUILTIN_SPLASH_PALETTES['kanagawa-wave']);
expect(document.documentElement.getAttribute('data-theme')).toBe('kanagawa-wave');
expect(document.documentElement.style.getPropertyValue('--startup-splash-bg').trim()).toBe('#1F1F28');
expect(document.documentElement.style.getPropertyValue('--startup-splash-accent').trim()).toBe('#7E9CD8');
expect(document.documentElement.style.getPropertyValue('--startup-splash-logo-start').trim()).toBe('#7E9CD8');
expect(document.documentElement.style.getPropertyValue('--startup-splash-logo-end').trim()).toBe('#957FB8');
});
it('reads custom logo gradient from community theme css', () => {
const palette = resolveSplashPalette('brand', [{
id: 'brand',
css: `[data-theme='brand'] { --bg-app: #111; --accent: #f00; --logo-color-start: #0f0; --logo-color-end: #00f; }`,
}]);
expect(palette.logoStart).toBe('#0f0');
expect(palette.logoEnd).toBe('#00f');
});
it('keeps public preflight palettes in sync with bundled theme map', () => {
const preflight = readFileSync(
resolve(process.cwd(), 'public/startup-splash-preflight.js'),
'utf8',
);
for (const themeId of Object.keys(BUILTIN_SPLASH_PALETTES)) {
expect(preflight).toContain(`'${themeId}'`);
expect(preflight).toContain(BUILTIN_SPLASH_PALETTES[themeId].bg);
expect(preflight).toContain(BUILTIN_SPLASH_PALETTES[themeId].logoStart);
}
});
});
+145
View File
@@ -0,0 +1,145 @@
import {
BUILTIN_SPLASH_PALETTES,
BUILTIN_THEME_IDS,
type StartupSplashPalette,
} from '../../config/startupSplashPalettes';
export type PersistedThemeState = {
enableThemeScheduler: boolean;
theme: string;
themeDay: string;
themeNight: string;
timeDayStart: string;
timeNightStart: string;
};
export type InstalledThemeSnapshot = {
id: string;
css: string;
};
const THEME_STORAGE_KEY = 'psysonic_theme';
const INSTALLED_THEMES_STORAGE_KEY = 'psysonic_installed_themes';
export function resolveScheduledThemeId(state: PersistedThemeState): string {
if (!state.enableThemeScheduler) return state.theme;
const now = new Date();
const nowMins = now.getHours() * 60 + now.getMinutes();
const [dh, dm] = state.timeDayStart.split(':').map(Number);
const [nh, nm] = state.timeNightStart.split(':').map(Number);
const dayMins = dh * 60 + dm;
const nightMins = nh * 60 + nm;
const isDay = dayMins < nightMins
? nowMins >= dayMins && nowMins < nightMins
: nowMins >= dayMins || nowMins < nightMins;
return isDay ? state.themeDay : state.themeNight;
}
export function readPersistedThemeState(): PersistedThemeState | null {
try {
const raw = localStorage.getItem(THEME_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as { state?: Record<string, unknown> };
const s = parsed.state;
if (!s) return null;
return {
enableThemeScheduler: !!s.enableThemeScheduler,
theme: String(s.theme ?? 'mocha'),
themeDay: String(s.themeDay ?? 'latte'),
themeNight: String(s.themeNight ?? 'mocha'),
timeDayStart: String(s.timeDayStart ?? '07:00'),
timeNightStart: String(s.timeNightStart ?? '19:00'),
};
} catch {
return null;
}
}
export function readInstalledThemesFromStorage(): InstalledThemeSnapshot[] {
try {
const raw = localStorage.getItem(INSTALLED_THEMES_STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as { state?: { themes?: InstalledThemeSnapshot[] } };
const themes = parsed.state?.themes;
return Array.isArray(themes) ? themes : [];
} catch {
return [];
}
}
export function resolveEffectiveThemeId(): string {
const persisted = readPersistedThemeState();
if (!persisted) return 'mocha';
return resolveScheduledThemeId(persisted);
}
function readCssVar(css: string, name: string): string | null {
const match = css.match(new RegExp(`${name}\\s*:\\s*([^;]+);`));
const value = match?.[1]?.trim();
return value && value.length > 0 ? value : null;
}
function resolveLogoColors(
css: string,
accent: string,
): Pick<StartupSplashPalette, 'logoStart' | 'logoEnd'> {
const logoStart = readCssVar(css, '--logo-color-start') ?? accent;
const logoEnd = readCssVar(css, '--logo-color-end')
?? readCssVar(css, '--accent-2')
?? accent;
return { logoStart, logoEnd };
}
function paletteFromCommunityCss(css: string, fallbackTrack: string): StartupSplashPalette | null {
const bg = readCssVar(css, '--bg-app');
const accent = readCssVar(css, '--accent');
if (!bg || !accent) return null;
const text = readCssVar(css, '--text-primary') ?? readCssVar(css, '--ctp-text') ?? '#cdd6f4';
const muted = readCssVar(css, '--text-muted') ?? readCssVar(css, '--ctp-subtext0') ?? text;
const track = readCssVar(css, '--bg-card') ?? readCssVar(css, '--border-subtle') ?? fallbackTrack;
return { bg, text, muted, accent, track, ...resolveLogoColors(css, accent) };
}
export function resolveSplashPalette(
themeId: string,
installedThemes: InstalledThemeSnapshot[] = readInstalledThemesFromStorage(),
): StartupSplashPalette {
const builtin = BUILTIN_SPLASH_PALETTES[themeId];
if (builtin) return builtin;
const installed = installedThemes.find(theme => theme.id === themeId);
if (installed) {
const fromCss = paletteFromCommunityCss(installed.css, BUILTIN_SPLASH_PALETTES.mocha.track);
if (fromCss) return fromCss;
}
return BUILTIN_SPLASH_PALETTES.mocha;
}
export function applyStartupSplashPalette(
themeId: string,
palette: StartupSplashPalette,
): void {
const root = document.documentElement;
root.setAttribute('data-theme', themeId);
root.style.setProperty('--startup-splash-bg', palette.bg);
root.style.setProperty('--startup-splash-text', palette.text);
root.style.setProperty('--startup-splash-muted', palette.muted);
root.style.setProperty('--startup-splash-accent', palette.accent);
root.style.setProperty('--startup-splash-track', palette.track);
root.style.setProperty('--startup-splash-logo-start', palette.logoStart);
root.style.setProperty('--startup-splash-logo-end', palette.logoEnd);
root.style.background = palette.bg;
document.body.style.background = palette.bg;
}
export function applyStartupSplashThemeFromStorage(): string {
const themeId = resolveEffectiveThemeId();
const palette = resolveSplashPalette(themeId);
applyStartupSplashPalette(themeId, palette);
return themeId;
}
export function isBuiltinThemeId(themeId: string): boolean {
return BUILTIN_THEME_IDS.includes(themeId);
}