diff --git a/assets/css/ui.css b/assets/css/ui.css index 51d2b39..a7c37ce 100644 --- a/assets/css/ui.css +++ b/assets/css/ui.css @@ -42,6 +42,7 @@ margin-bottom: 12px; } +/* True crossfade — outgoing slide sits absolutely on top and fades out */ .event-banner { display: flex; align-items: center; @@ -49,17 +50,21 @@ padding: 13px 18px; border-radius: 4px; position: relative; + z-index: 1; border-left: 4px solid rgba(255,255,255,0.25); } - -/* Single-element crossfade — content swaps during the transition gap */ -.event-banner.banner-exiting { - animation: banner-fade-out 0.4s ease both; +.event-banner--outgoing { + position: absolute; + top: 0; + left: 0; + width: 100%; + z-index: 2; pointer-events: none; + animation: banner-fade-out 0.45s ease both; } - -.event-banner.banner-entering { - animation: banner-fade-in 0.4s ease both; +.event-banner-stage { + position: relative; + overflow: hidden; } .event-banner-emoji { font-size: 24px; diff --git a/assets/js/app.js b/assets/js/app.js index 784a80d..63433b7 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -480,10 +480,12 @@ export function UTCIForecast() { const BANNER_SNOOZE_KEY = id => `sunscope.banner.snoozed.${id}`; const [bannerIndex, setBannerIndex] = useState(0); const [bannerTransition, setBannerTransition] = useState(null); // 'entering' | 'exiting' | null + const [bannerPrevIndex, setBannerPrevIndex] = useState(null); // bannerVisible drives the .visible class — kept true until the collapse // animation finishes so content doesn't vanish before the wrap closes. const [bannerVisible, setBannerVisible] = useState(false); const bannerIndexRef = useRef(0); + const bannerStageRef = useRef(null); useEffect(() => { bannerIndexRef.current = bannerIndex; }, [bannerIndex]); const isSnoozed = id => { @@ -504,14 +506,31 @@ export function UTCIForecast() { if (activeEvents.length > 0) setBannerVisible(true); }, [activeEvents.length]); - // Crossfade to a new banner index: fade out → swap → fade in + // True crossfade: render prev + next simultaneously, prev fades out on top + // Height is locked to current value then released after transition so it + // animates smoothly between slides of different heights. const bannerSlideTo = useCallback((next) => { - setBannerTransition('exiting'); + const stage = bannerStageRef.current; + if (stage) { + // Lock current height so CSS transition has a start point + stage.style.height = stage.offsetHeight + 'px'; + stage.style.transition = 'height 0.45s cubic-bezier(0.4,0,0.2,1)'; + } + setBannerPrevIndex(bannerIndexRef.current); + setBannerIndex(next); + setBannerTransition('crossfading'); + // After Preact re-renders with new slide, measure new height and animate to it setTimeout(() => { - setBannerIndex(next); - setBannerTransition('entering'); - setTimeout(() => setBannerTransition(null), 420); - }, 400); + if (stage) { + const incoming = stage.querySelector('.event-banner:not(.event-banner--outgoing)'); + if (incoming) stage.style.height = incoming.offsetHeight + 'px'; + } + }, 16); + setTimeout(() => { + setBannerTransition(null); + setBannerPrevIndex(null); + if (stage) { stage.style.height = ''; stage.style.transition = ''; } + }, 460); }, []); // Dismiss with animation: fade stage → collapse wrap → remove event @@ -563,6 +582,7 @@ export function UTCIForecast() { `; + }; + return html` +
+ ${bannerTransition === 'crossfading' && bannerPrevIndex !== null + ? renderSlide(bannerPrevIndex, true) + : null} + ${renderSlide(bannerIndex, false)}
`; })()} @@ -1265,7 +1291,7 @@ ${isPro && (activeProfile === 'custom' || activeCols['air']) && html`