feat(themes): crossfade theme changes and fix window overflow

Crossfade user-selected themes, keep the theme volume popover open while
dragging its slider, and clip the fixed app shell so oversized theme layers
cannot create page scrollbars on Windows. Remove the redundant in-use badge
from the active theme card.
This commit is contained in:
HorizonCode committed 2026-11-04 21:43:24 +01:00
1 parent c2fd265b49
commit 8616253065
5 files changed
+113 -9

No files matched your search

+32 -1
View File
@@ -162,7 +162,10 @@
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
} }
html { html,
body {
height: 100%;
overflow: hidden;
overscroll-behavior: none; overscroll-behavior: none;
} }
body { body {
@@ -197,3 +200,31 @@ video::-webkit-media-controls-overlay-enclosure {
width: 0 !important; width: 0 !important;
height: 0 !important; height: 0 !important;
} }
/* Crossfade the rendered page when switching themes via the View Transitions API. */
::view-transition-old(root) {
animation: 320ms cubic-bezier(0.4, 0, 0.2, 1) both theme-crossfade-out;
}
::view-transition-new(root) {
animation: 320ms cubic-bezier(0.4, 0, 0.2, 1) both theme-crossfade-in;
}
@keyframes theme-crossfade-out {
to {
opacity: 0;
}
}
@keyframes theme-crossfade-in {
from {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none !important;
}
}
+42 -3
View File
@@ -16,6 +16,34 @@
const iconSet = $derived($active_custom_theme?.definition?.iconSet ?? DEFAULT_THEME_ICON_SET); const iconSet = $derived($active_custom_theme?.definition?.iconSet ?? DEFAULT_THEME_ICON_SET);
let volumeOpen = $state(false);
let volumeDragging = $state(false);
let volumeControl: HTMLDivElement | undefined = $state();
function openVolume() {
volumeOpen = true;
}
function closeVolume() {
if (!volumeDragging) volumeOpen = false;
}
function startVolumeDrag(event: PointerEvent) {
if (event.button !== 0) return;
volumeDragging = true;
volumeOpen = true;
}
function endVolumeDrag(event: PointerEvent) {
if (!volumeDragging) return;
volumeDragging = false;
const element = document.elementFromPoint(event.clientX, event.clientY);
if (!element || !volumeControl?.contains(element)) {
volumeOpen = false;
}
}
onMount(() => { onMount(() => {
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
@@ -26,9 +54,18 @@
}); });
</script> </script>
<svelte:window onpointerup={endVolumeDrag} onpointercancel={endVolumeDrag} />
<div data-tauri-drag-region class="titlebar z-[100]" class:macos-window={$platform === 'macos'}> <div data-tauri-drag-region class="titlebar z-[100]" class:macos-window={$platform === 'macos'}>
{#if $custom_theme_audio_playing || ($active_custom_theme && $active_custom_theme.name !== 'Default')} {#if $custom_theme_audio_playing || ($active_custom_theme && $active_custom_theme.name !== 'Default')}
<div class="relative flex items-center justify-center group"> <div
class="relative flex items-center justify-center group"
role="group"
aria-label="Theme volume"
bind:this={volumeControl}
onpointerenter={openVolume}
onpointerleave={closeVolume}
>
<div class="relative flex items-center justify-center p-1 cursor-pointer"> <div class="relative flex items-center justify-center p-1 cursor-pointer">
<div <div
class="absolute inset-0 bg-background rounded class="absolute inset-0 bg-background rounded
@@ -51,8 +88,9 @@
<div <div
class="absolute top-full right-0 translate-x-[7px] class="absolute top-full right-0 translate-x-[7px]
opacity-0 invisible pointer-events-none -translate-y-2 {volumeOpen
group-hover:opacity-100 group-hover:visible group-hover:pointer-events-auto group-hover:translate-y-0 ? 'opacity-100 visible pointer-events-auto translate-y-0'
: 'opacity-0 invisible pointer-events-none -translate-y-2'}
{$reduceAnimations ? '' : 'transition-all duration-200 ease-out'} {$reduceAnimations ? '' : 'transition-all duration-200 ease-out'}
z-50" z-50"
> >
@@ -68,6 +106,7 @@
max="1" max="1"
step="0.01" step="0.01"
bind:value={$custom_theme_volume} bind:value={$custom_theme_volume}
onpointerdown={startVolumeDrag}
oninput={() => { oninput={() => {
config.value<number>('volume').set($custom_theme_volume); config.value<number>('volume').set($custom_theme_volume);
}} }}
+35 -1
View File
@@ -7,6 +7,7 @@ import { convertFileSrc, invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import * as path from '@tauri-apps/api/path'; import * as path from '@tauri-apps/api/path';
import * as fs from '@tauri-apps/plugin-fs'; import * as fs from '@tauri-apps/plugin-fs';
import { tick } from 'svelte';
export type Theme = { export type Theme = {
folder_name: string; folder_name: string;
@@ -486,7 +487,18 @@ export const downloadTheme = async (
return { success: true }; return { success: true };
}; };
export const loadTheme = async (theme: Theme, themeContainer: HTMLElement, volume = 0.15) => { type LoadThemeOptions = {
/** Crossfade the rendered page when switching themes. */
animate?: boolean;
};
export const loadTheme = async (
theme: Theme,
themeContainer: HTMLElement,
volume = 0.15,
options: LoadThemeOptions = {}
) => {
const applyTheme = async () => {
// Theme media is buffered as blob URLs; the previous theme's URLs are no // Theme media is buffered as blob URLs; the previous theme's URLs are no
// longer needed once it is replaced. // longer needed once it is replaced.
clearThemeMediaCache(); clearThemeMediaCache();
@@ -516,6 +528,28 @@ export const loadTheme = async (theme: Theme, themeContainer: HTMLElement, volum
void invoke('unlock_media_autoplay').catch(() => {}); void invoke('unlock_media_autoplay').catch(() => {});
}; };
const canAnimate =
options.animate &&
typeof document !== 'undefined' &&
typeof document.startViewTransition === 'function';
if (!canAnimate) {
await applyTheme();
return;
}
const transition = document.startViewTransition(async () => {
await applyTheme();
// Let Svelte paint the new theme before the transition takes its snapshot.
await tick();
});
// Theme activation must not wait for, or fail because of, the visual
// transition. `finished` can reject when a transition is skipped.
void transition.finished.catch(() => {});
await transition.updateCallbackDone;
};
export const deleteTheme = async (themeToUninstall: Theme) => { export const deleteTheme = async (themeToUninstall: Theme) => {
if (themeToUninstall.status !== 'installed') { if (themeToUninstall.status !== 'installed') {
return false; return false;
+1
View File
@@ -583,6 +583,7 @@
</AlertDialog.Root> </AlertDialog.Root>
<main <main
class="relative h-screen w-full overflow-hidden"
use:appActivity use:appActivity
use:themeMediaSuspension use:themeMediaSuspension
use:themeParallax={{ use:themeParallax={{
+3 -4
View File
@@ -526,7 +526,9 @@
return; return;
} }
loadTheme(theme, $custom_theme_container, $custom_theme_volume); await loadTheme(theme, $custom_theme_container, $custom_theme_volume, {
animate: !$reduceAnimations,
});
await config.value<string>('theme').set(theme.name); await config.value<string>('theme').set(theme.name);
}; };
@@ -4511,9 +4513,6 @@
<div <div
class="absolute top-1.5 left-1.5 flex max-w-[calc(100%-4.5rem)] flex-wrap items-center gap-1" class="absolute top-1.5 left-1.5 flex max-w-[calc(100%-4.5rem)] flex-wrap items-center gap-1"
> >
{#if isActive}
<Badge class="text-[9px]">In use</Badge>
{/if}
{#if theme.name !== 'Default' && theme.folder_name !== 'default' && theme.apiVersion} {#if theme.name !== 'Default' && theme.folder_name !== 'default' && theme.apiVersion}
<Badge <Badge
variant="secondary" variant="secondary"