feat(titlebar): selectable window button styles + minimize toggle (#1083)

* feat(titlebar): selectable window button styles + minimize toggle

Custom title bar (Linux) gains a window-button style picker, mirroring
the seekbar style picker pattern. Six form-named styles: dots, dotsGlyph,
flat, pill, outline, glyph. All buttons now carry minimize/maximize/close
glyphs for clear, colour-blind-friendly iconography; dots reveal glyphs on
hover, dotsGlyph always shows them.

- New authStore state windowButtonStyle (default dots) + showMinimizeButton,
  with rehydrate validation falling back to dots on unknown values.
- WindowButtonPreview reuses the real .titlebar-btn classes for WYSIWYG tiles.
- Picker + minimize toggle render under the Custom title bar setting, gated
  on the toggle being on.
- Monochrome styles use --text-primary glyphs and stronger borders for
  contrast on dark themes.
- Dev-build grey marker scoped to the real title bar so previews show true
  colours.
- i18n keys in all 9 locales; setter and rehydrate tests.

* docs(changelog): window button styles (#1083)
This commit is contained in:
Psychotoxical
2026-06-13 23:52:56 +02:00
committed by GitHub
parent be3f1dc299
commit 028eb65f7d
21 changed files with 386 additions and 47 deletions
+22
View File
@@ -131,6 +131,28 @@ describe('onRehydrate migrations', () => {
expect(useAuthStore.getState().seekbarStyle).toBe('neon');
});
it('falls back an invalid windowButtonStyle to `dots`', async () => {
writePersistedState({
servers: [],
activeServerId: null,
windowButtonStyle: 'bogus', // not in VALID_WINDOW_BUTTON_STYLES
});
await useAuthStore.persist.rehydrate();
expect(useAuthStore.getState().windowButtonStyle).toBe('dots');
});
it('keeps a valid windowButtonStyle unchanged', async () => {
writePersistedState({
servers: [],
activeServerId: null,
windowButtonStyle: 'glyph',
});
await useAuthStore.persist.rehydrate();
expect(useAuthStore.getState().windowButtonStyle).toBe('glyph');
});
it('strips the removed `animationMode` and `reducedAnimations` legacy fields', async () => {
writePersistedState({
servers: [],
+2
View File
@@ -58,6 +58,8 @@ describe('trivial pass-through setters', () => {
['setDiscordRichPresence', 'discordRichPresence', true],
['setEnableBandsintown', 'enableBandsintown', true],
['setUseCustomTitlebar', 'useCustomTitlebar', true],
['setWindowButtonStyle', 'windowButtonStyle', 'flat'],
['setShowMinimizeButton', 'showMinimizeButton', false],
['setPreloadMiniPlayer', 'preloadMiniPlayer', true],
['setLinuxWebkitKineticScroll', 'linuxWebkitKineticScroll', false],
['setLinuxWaylandTextRenderProfile', 'linuxWaylandTextRenderProfile', 'gpu'],
+2
View File
@@ -74,6 +74,8 @@ export const useAuthStore = create<AuthState>()(
discordTemplateLargeText: '{album}',
discordTemplateName: '{title}',
useCustomTitlebar: false,
windowButtonStyle: 'dots',
showMinimizeButton: true,
preloadMiniPlayer: false,
linuxWebkitKineticScroll: true,
linuxWaylandTextRenderProfile: 'sharp',
+13
View File
@@ -19,6 +19,7 @@ import type {
LyricsSourceConfig,
QueueDisplayMode,
SeekbarStyle,
WindowButtonStyle,
} from './authStoreTypes';
import { migrateLegacyLastfm, sanitizeAccounts } from '../music-network';
@@ -93,6 +94,17 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
? {}
: { seekbarStyle: 'truewave' as SeekbarStyle };
// Unknown / missing / tampered window-button style falls back to the
// default 'dots' so the title bar never renders an unstyled data-attr.
const VALID_WINDOW_BUTTON_STYLES = new Set<string>([
'dots', 'dotsGlyph', 'flat', 'pill', 'outline', 'glyph',
]);
const windowButtonStyleMigrated = VALID_WINDOW_BUTTON_STYLES.has(
(state as { windowButtonStyle?: unknown }).windowButtonStyle as string,
)
? {}
: { windowButtonStyle: 'dots' as WindowButtonStyle };
// Garbage / null / undefined / missing key from a legacy or tampered persist
// payload maps back to 'total' so the duration chip never receives an
// unknown mode (would render an empty label).
@@ -236,6 +248,7 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
...youLyPlusMigrated,
...wheelSmoothOneTime,
...seekbarStyleMigrated,
...windowButtonStyleMigrated,
...queueDurationDisplayModeMigrated,
...queueDisplayModeMigrated,
...linuxWaylandTextRenderProfileMigrated,
+17
View File
@@ -33,6 +33,17 @@ export interface ServerProfile {
}
export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thick' | 'segmented' | 'neon' | 'pulsewave' | 'particletrail' | 'liquidfill' | 'retrotape';
/**
* Look of the custom-title-bar window buttons (minimize/maximize/close).
* Form-descriptive names, not OS brands:
* - `dots`: coloured traffic-light circles, glyphs appear on hover (default).
* - `dotsGlyph`: traffic-light circles with always-visible glyphs (colour + shape).
* - `flat`: full-height rectangular buttons with line glyphs, red close hover.
* - `pill`: soft circular monochrome buttons with glyphs.
* - `outline`: square bordered buttons with thin glyphs, accent hover.
* - `glyph`: themed monochrome glyphs only, no background — blends with the app.
*/
export type WindowButtonStyle = 'dots' | 'dotsGlyph' | 'flat' | 'pill' | 'outline' | 'glyph';
/** Queue header duration chip: total duration / time left / ETA finish clock. */
export type DurationMode = 'total' | 'remaining' | 'eta';
@@ -143,6 +154,10 @@ export interface AuthState {
* Empty string falls back to "Psysonic". */
discordTemplateName: string;
useCustomTitlebar: boolean;
/** Look of the custom-title-bar window buttons (Linux custom title bar only). */
windowButtonStyle: WindowButtonStyle;
/** Show the minimize button in the custom title bar. Off = only maximize + close. */
showMinimizeButton: boolean;
/** Pre-build the mini-player webview at app start on Linux/macOS so content is available instantly
* on first open. Ignored on Windows — that platform always pre-creates as a hang workaround. */
preloadMiniPlayer: boolean;
@@ -344,6 +359,8 @@ export interface AuthState {
setDiscordTemplateLargeText: (v: string) => void;
setDiscordTemplateName: (v: string) => void;
setUseCustomTitlebar: (v: boolean) => void;
setWindowButtonStyle: (v: WindowButtonStyle) => void;
setShowMinimizeButton: (v: boolean) => void;
setPreloadMiniPlayer: (v: boolean) => void;
setLinuxWebkitKineticScroll: (v: boolean) => void;
setLinuxWaylandTextRenderProfile: (v: LinuxWaylandTextRenderProfile) => void;
+4
View File
@@ -19,6 +19,8 @@ export function createUiAppearanceActions(set: SetState): Pick<
| 'setClockFormat'
| 'setShowOrbitTrigger'
| 'setUseCustomTitlebar'
| 'setWindowButtonStyle'
| 'setShowMinimizeButton'
| 'setPreloadMiniPlayer'
| 'setLinuxWebkitKineticScroll'
| 'setLinuxWaylandTextRenderProfile'
@@ -41,6 +43,8 @@ export function createUiAppearanceActions(set: SetState): Pick<
setClockFormat: (v) => set({ clockFormat: v }),
setShowOrbitTrigger: (v) => set({ showOrbitTrigger: v }),
setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }),
setWindowButtonStyle: (v) => set({ windowButtonStyle: v }),
setShowMinimizeButton: (v) => set({ showMinimizeButton: v }),
setPreloadMiniPlayer: (v) => set({ preloadMiniPlayer: v }),
setLinuxWebkitKineticScroll: (v) => set({ linuxWebkitKineticScroll: v }),
setLinuxWaylandTextRenderProfile: (v) => set({ linuxWaylandTextRenderProfile: v }),