home loading bug and flashes fixes
This commit is contained in:
@@ -261,6 +261,7 @@
|
||||
"table_now": "Jetzt",
|
||||
"interval_toggle": "Zwischen 1- und 3-Stunden-Intervall wechseln",
|
||||
"page_loading": "Wird geladen…",
|
||||
"page_loading_dismiss": "Schließen",
|
||||
"charts_loading": "Diagramme werden geladen…",
|
||||
"chart_download": "Meteogramm als PNG-Bild herunterladen",
|
||||
"chart_credit_viz": "Visualisierung von",
|
||||
|
||||
@@ -261,6 +261,7 @@
|
||||
"table_now": "Now",
|
||||
"interval_toggle": "Toggle between 1-hour and 3-hour intervals",
|
||||
"page_loading": "Loading…",
|
||||
"page_loading_dismiss": "Dismiss",
|
||||
"charts_loading": "Loading charts...",
|
||||
"chart_download": "Download meteogram as PNG image",
|
||||
"chart_credit_viz": "visualisation by",
|
||||
|
||||
@@ -261,6 +261,7 @@
|
||||
"table_now": "Ahora",
|
||||
"interval_toggle": "Alternar entre intervalos de 1 y 3 horas",
|
||||
"page_loading": "Cargando…",
|
||||
"page_loading_dismiss": "Descartar",
|
||||
"charts_loading": "Cargando gráficos…",
|
||||
"chart_download": "Descargar el meteograma como imagen PNG",
|
||||
"chart_credit_viz": "visualización de",
|
||||
|
||||
@@ -261,6 +261,7 @@
|
||||
"table_now": "Maintenant",
|
||||
"interval_toggle": "Basculer entre les intervalles de 1 h et 3 h",
|
||||
"page_loading": "Chargement…",
|
||||
"page_loading_dismiss": "Fermer",
|
||||
"charts_loading": "Chargement des graphiques…",
|
||||
"chart_download": "Télécharger le météogramme en PNG",
|
||||
"chart_credit_viz": "visualisation par",
|
||||
|
||||
@@ -261,6 +261,7 @@
|
||||
"table_now": "Ora",
|
||||
"interval_toggle": "Alterna tra intervalli di 1 e 3 ore",
|
||||
"page_loading": "Caricamento…",
|
||||
"page_loading_dismiss": "Chiudi",
|
||||
"charts_loading": "Caricamento dei grafici…",
|
||||
"chart_download": "Scarica il meteogramma come immagine PNG",
|
||||
"chart_credit_viz": "visualizzazione di",
|
||||
|
||||
@@ -22,6 +22,16 @@ export function markPageLoading(): void {
|
||||
pageContentReady.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The counterpart, for a route that has nothing to wait for (the maps page, the
|
||||
* legal pages). Without it a navigation away from a page that never resolved
|
||||
* would leave the flag stuck on "loading", and the layout's overlay would sit on
|
||||
* top of a page that is perfectly finished.
|
||||
*/
|
||||
export function markPageReady(): void {
|
||||
pageContentReady.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare a page's readiness. Pass a getter for "my data has arrived". Only
|
||||
* ever sets the flag - clearing it is the layout's job (see above).
|
||||
|
||||
+13
-15
@@ -1,7 +1,11 @@
|
||||
import { tick } from 'svelte';
|
||||
|
||||
const prefersReducedMotion = () =>
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
import {
|
||||
isViewTransitionActive,
|
||||
prefersReducedMotion,
|
||||
startViewTransition,
|
||||
supportsViewTransitions
|
||||
} from './view-transition';
|
||||
|
||||
/**
|
||||
* Runs a day change inside a view transition, so the outgoing day is still on
|
||||
@@ -12,9 +16,13 @@ const prefersReducedMotion = () =>
|
||||
* `.day-region-*` in routes/layout.css); everything else - the strip, the
|
||||
* header, the page chrome - is pinned by the `day-switch` class so it stays
|
||||
* completely still.
|
||||
*
|
||||
* A day switch that lands while a navigation transition is still on screen just
|
||||
* applies: starting a rival transition would skip the running one and flash the
|
||||
* page (see view-transition.ts).
|
||||
*/
|
||||
export async function runDayTransition(update: () => void): Promise<void> {
|
||||
if (prefersReducedMotion() || !document.startViewTransition) {
|
||||
if (!supportsViewTransitions() || prefersReducedMotion() || isViewTransitionActive()) {
|
||||
update();
|
||||
return;
|
||||
}
|
||||
@@ -24,20 +32,10 @@ export async function runDayTransition(update: () => void): Promise<void> {
|
||||
try {
|
||||
// Svelte applies the change on the next tick; the transition has to wait
|
||||
// for that before it snapshots the new state.
|
||||
const transition = document.startViewTransition(async () => {
|
||||
await startViewTransition(async () => {
|
||||
update();
|
||||
await tick();
|
||||
});
|
||||
// A throw inside the callback rejects `updateCallbackDone` as well as
|
||||
// `finished`. Nothing awaits the former, and that unhandled rejection is
|
||||
// what turns one bad render into an "Uncaught" error on the page - so
|
||||
// report it here instead and let `finished` drive the flow.
|
||||
transition.updateCallbackDone.catch((error: unknown) => {
|
||||
console.error('day transition failed to apply', error);
|
||||
});
|
||||
await transition.finished;
|
||||
} catch {
|
||||
/* a superseded transition is fine - the DOM is already up to date */
|
||||
} finally {
|
||||
root.classList.remove('day-switch');
|
||||
}
|
||||
@@ -53,7 +51,7 @@ export function daySwap(node: HTMLElement, key: unknown) {
|
||||
|
||||
const play = () => {
|
||||
// view transitions handle it properly where they exist
|
||||
if (typeof document.startViewTransition === 'function' || prefersReducedMotion()) return;
|
||||
if (supportsViewTransitions() || prefersReducedMotion()) return;
|
||||
node.animate([{ opacity: 0.1 }, { opacity: 1 }], {
|
||||
duration: 460,
|
||||
easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)'
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* One view transition at a time.
|
||||
*
|
||||
* The browser runs exactly one: starting a second while the first is still on
|
||||
* screen *skips* the first, which drops its frozen snapshot on the spot and
|
||||
* pops the live DOM into view. That is the whole-screen flash, and overlapping
|
||||
* transitions are the normal case here rather than an edge case:
|
||||
*
|
||||
* - `/` and every bare `/weather/<view>/` page is a redirect stub that calls
|
||||
* `goto` from `onMount`, so one click on a nav entry produces two (from the
|
||||
* root, three) navigations back to back - and the stub renders nothing, so
|
||||
* the frame it exposes is an empty page;
|
||||
* - a theme toggle or a day switch can land while a navigation is still
|
||||
* waiting for its forecast.
|
||||
*
|
||||
* Letting a second update ride along inside the transition that is already
|
||||
* running keeps one continuous cross-fade across the whole chain.
|
||||
*/
|
||||
type UpdateCallback = () => void | Promise<void>;
|
||||
|
||||
let active: Promise<void> | null = null;
|
||||
|
||||
export const supportsViewTransitions = (): boolean =>
|
||||
typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
|
||||
|
||||
export const prefersReducedMotion = (): boolean =>
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
/** True while a transition is on screen, from capture until its animation ends. */
|
||||
export const isViewTransitionActive = (): boolean => active !== null;
|
||||
|
||||
/**
|
||||
* Runs `update` inside a view transition, or straight away when one cannot (or
|
||||
* must not) be started. Resolves once the transition has finished animating -
|
||||
* or as soon as the update is done, when it ran on its own.
|
||||
*
|
||||
* Callers that need to know which of the two happened should check
|
||||
* `isViewTransitionActive()` first; the point of this function is that they
|
||||
* mostly should not care.
|
||||
*/
|
||||
export function startViewTransition(update: UpdateCallback): Promise<void> {
|
||||
if (!supportsViewTransitions() || prefersReducedMotion() || active) {
|
||||
return Promise.resolve(update()).then(
|
||||
() => {},
|
||||
(error: unknown) => {
|
||||
console.error('view transition update failed', error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const transition = document.startViewTransition(update);
|
||||
|
||||
// A throw inside the callback rejects `updateCallbackDone` as well as
|
||||
// `finished`. Nothing awaits the former, and an unhandled rejection there is
|
||||
// what turns one bad render into an "Uncaught" error on the page.
|
||||
transition.updateCallbackDone.catch((error: unknown) => {
|
||||
console.error('view transition update failed', error);
|
||||
});
|
||||
|
||||
// A superseded transition rejects `finished`; the DOM is already up to date,
|
||||
// so that is not an error worth surfacing - but the slot has to be freed.
|
||||
const done = transition.finished.then(
|
||||
() => {},
|
||||
() => {}
|
||||
);
|
||||
active = done;
|
||||
void done.then(() => {
|
||||
if (active === done) active = null;
|
||||
});
|
||||
|
||||
return done;
|
||||
}
|
||||
+125
-67
@@ -6,9 +6,20 @@
|
||||
import { afterNavigate, onNavigate } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
import { markPageLoading, pageContentReady } from '$lib/stores/page-transition.svelte';
|
||||
import {
|
||||
markPageLoading,
|
||||
markPageReady,
|
||||
pageContentReady
|
||||
} from '$lib/stores/page-transition.svelte';
|
||||
import { storedTheme } from '$lib/stores/settings';
|
||||
|
||||
import {
|
||||
isViewTransitionActive,
|
||||
prefersReducedMotion,
|
||||
startViewTransition,
|
||||
supportsViewTransitions
|
||||
} from '$lib/utils/view-transition';
|
||||
|
||||
import Footer from '$lib/components/navigation/footer.svelte';
|
||||
import Header from '$lib/components/navigation/header.svelte';
|
||||
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
|
||||
@@ -30,7 +41,9 @@
|
||||
const apply = () => {
|
||||
const root = document.documentElement;
|
||||
const dark = theme === 'dark' || (theme === 'system' && mq.matches);
|
||||
const paint = () => root.classList.toggle('dark', dark);
|
||||
const paint = () => {
|
||||
root.classList.toggle('dark', dark);
|
||||
};
|
||||
|
||||
// The very first application is just painting the stored theme - only
|
||||
// an actual switch afterwards is worth cross-fading.
|
||||
@@ -41,9 +54,17 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.startViewTransition) {
|
||||
if (supportsViewTransitions() && !isViewTransitionActive()) {
|
||||
// one cross-fade of the whole document; component transitions untouched
|
||||
document.startViewTransition(paint);
|
||||
void startViewTransition(paint);
|
||||
return;
|
||||
}
|
||||
|
||||
// A navigation transition already owns the screen: repaint under it
|
||||
// rather than skipping it (which would flash), and fall back to the
|
||||
// colour transition below when the browser has none at all.
|
||||
if (supportsViewTransitions()) {
|
||||
paint();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,43 +93,67 @@
|
||||
// yet. Note the order: the overlay has to be in the DOM *before* the
|
||||
// transition captures the incoming state, because a running view transition
|
||||
// freezes the page and nothing painted after that point can appear.
|
||||
//
|
||||
// There is deliberately no fade for browsers without view transitions. The
|
||||
// old fallback dimmed the outgoing page to nothing and brought the new one
|
||||
// back up, which on a single layer is a flash of the bare background rather
|
||||
// than a cross-fade. Swapping outright and letting the overlay carry the
|
||||
// "loading" message is quieter and honest.
|
||||
const READY_HOLD_MS = 350;
|
||||
const FADE_OUT_MS = 170;
|
||||
// Nothing reports ready when a fetch fails outright, so the overlay needs its
|
||||
// own way out rather than sitting on top of an error message forever.
|
||||
const OVERLAY_CEILING_MS = 15000;
|
||||
|
||||
let contentVisible = $state(true);
|
||||
let revealTimer = 0;
|
||||
|
||||
let loadingOverlay = $state(false);
|
||||
let overlayHoldTimer = 0;
|
||||
let overlayCeilingTimer = 0;
|
||||
// A view transition freezes the page, so a Svelte in-transition started under
|
||||
// one cannot play - the overlay would be captured at opacity 0 and pop in
|
||||
// afterwards. Inside a transition the cross-fade does the fading instead.
|
||||
let overlayFadesIn = $state(true);
|
||||
|
||||
function showOverlay(): void {
|
||||
function showOverlay(animate: boolean): void {
|
||||
overlayFadesIn = animate;
|
||||
loadingOverlay = true;
|
||||
clearTimeout(overlayCeilingTimer);
|
||||
overlayCeilingTimer = window.setTimeout(() => (loadingOverlay = false), OVERLAY_CEILING_MS);
|
||||
}
|
||||
|
||||
function hideOverlay(): void {
|
||||
clearTimeout(overlayHoldTimer);
|
||||
clearTimeout(overlayCeilingTimer);
|
||||
loadingOverlay = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual dismissal. The overlay reports on a fetch it does not control, so
|
||||
* "stuck" is always a possibility (a page that never reports ready, a request
|
||||
* that neither resolves nor rejects) - and the page behind it still works.
|
||||
* Whatever was loading carries on; only the veil goes.
|
||||
*/
|
||||
function dismissOverlay(): void {
|
||||
hideOverlay();
|
||||
}
|
||||
|
||||
function onWindowKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape' && loadingOverlay) dismissOverlay();
|
||||
}
|
||||
|
||||
/** The page that just mounted has its data: whatever we were waiting for is in. */
|
||||
$effect(() => {
|
||||
if ($pageContentReady) hideOverlay();
|
||||
});
|
||||
|
||||
const reducedMotion = () =>
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
// Routes that fetch their own forecast after mounting. Knowing this up front
|
||||
// is what makes the wait reliable: the layout clears the ready flag before
|
||||
// the swap rather than trusting the incoming page to have done it.
|
||||
const DATA_ROUTES = new Set([
|
||||
// Routes that are not finished on arrival: the five that fetch a forecast
|
||||
// after mounting, and the redirect stubs, which render nothing at all and
|
||||
// bounce to a located URL from `onMount`. Knowing this up front is what makes
|
||||
// the wait reliable - the layout clears the ready flag before the swap rather
|
||||
// than trusting the incoming page to have done it.
|
||||
const PENDING_ROUTES = new Set([
|
||||
'/',
|
||||
'/weather/week',
|
||||
'/weather/14-day',
|
||||
'/weather/compare',
|
||||
'/weather/seasonal',
|
||||
'/weather/historical',
|
||||
'/weather/week/[location]',
|
||||
'/weather/14-day/[location]',
|
||||
'/weather/compare/[location]',
|
||||
@@ -121,34 +166,53 @@
|
||||
* period is up - in which case the overlay goes up first, so it is part of
|
||||
* the state the transition is about to snapshot.
|
||||
*/
|
||||
async function waitForContent(): Promise<void> {
|
||||
async function waitForContent(underTransition: boolean): Promise<void> {
|
||||
const deadline = Date.now() + READY_HOLD_MS;
|
||||
while (!get(pageContentReady) && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
if (!get(pageContentReady)) showOverlay();
|
||||
if (!get(pageContentReady)) showOverlay(!underTransition);
|
||||
// one more frame so the page paints its data before the snapshot is taken
|
||||
await tick();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the navigation lands on the route and params the page is already
|
||||
* showing - the sidebar's home link from the 7-day page it points at, or a
|
||||
* link that differs only in the query string.
|
||||
*
|
||||
* SvelteKit keeps the page component mounted for those, and nothing it holds
|
||||
* changes: its forecast is already fetched, so the readiness effect it
|
||||
* registered never re-runs and never re-announces. Clearing the flag for such
|
||||
* a navigation strands it cleared, and the overlay sits there until its
|
||||
* ceiling. There is genuinely nothing to wait for, so don't clear it.
|
||||
*/
|
||||
function landsOnCurrentPage(navigation: {
|
||||
from: { route: { id: string | null }; params: Record<string, string> | null } | null;
|
||||
to: { route: { id: string | null }; params: Record<string, string> | null } | null;
|
||||
}): boolean {
|
||||
const from = navigation.from;
|
||||
const to = navigation.to;
|
||||
if (!from?.route.id || from.route.id !== to?.route.id) return false;
|
||||
return JSON.stringify(from.params ?? {}) === JSON.stringify(to.params ?? {});
|
||||
}
|
||||
|
||||
onNavigate((navigation) => {
|
||||
const loadsData = DATA_ROUTES.has(navigation.to?.route?.id ?? '');
|
||||
if (loadsData) markPageLoading();
|
||||
const pending =
|
||||
PENDING_ROUTES.has(navigation.to?.route?.id ?? '') && !landsOnCurrentPage(navigation);
|
||||
// Either way the flag is set explicitly: leaving a page that never resolved
|
||||
// for one that has nothing to load would otherwise strand the overlay.
|
||||
if (pending) markPageLoading();
|
||||
else markPageReady();
|
||||
|
||||
if (reducedMotion()) {
|
||||
// No transition to hang the wait on, so the overlay gets its own timer.
|
||||
if (loadsData) {
|
||||
clearTimeout(overlayHoldTimer);
|
||||
overlayHoldTimer = window.setTimeout(() => {
|
||||
if (!get(pageContentReady)) showOverlay();
|
||||
}, READY_HOLD_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// `startViewTransition` decides whether a transition is possible at all
|
||||
// (support, reduced motion, one already on screen) and runs the update
|
||||
// inline when it is not - so there is exactly one path from here down.
|
||||
const underTransition =
|
||||
supportsViewTransitions() && !prefersReducedMotion() && !isViewTransitionActive();
|
||||
|
||||
if (typeof document.startViewTransition === 'function') {
|
||||
return new Promise<void>((swap) => {
|
||||
document.startViewTransition(async () => {
|
||||
void startViewTransition(async () => {
|
||||
// hand control back so SvelteKit swaps the DOM underneath the
|
||||
// frozen snapshot of the old page
|
||||
swap();
|
||||
@@ -156,15 +220,9 @@
|
||||
// rejects this promise; that is not an error worth surfacing,
|
||||
// and leaving it unhandled shows up as "navigation aborted".
|
||||
await navigation.complete.catch(() => {});
|
||||
if (loadsData) await waitForContent();
|
||||
if (pending) await waitForContent(underTransition);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// No view transitions: fall back to fading out, then back in on arrival.
|
||||
clearTimeout(revealTimer);
|
||||
contentVisible = false;
|
||||
return new Promise((resolve) => setTimeout(resolve, FADE_OUT_MS));
|
||||
});
|
||||
|
||||
let mainEl = $state<HTMLElement | null>(null);
|
||||
@@ -176,26 +234,6 @@
|
||||
if (navigation.type !== 'popstate' && !navigation.to?.url.hash) {
|
||||
mainEl?.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
if (typeof document.startViewTransition === 'function' || reducedMotion()) {
|
||||
contentVisible = true;
|
||||
return;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
const reveal = () => {
|
||||
if (get(pageContentReady)) {
|
||||
contentVisible = true;
|
||||
return;
|
||||
}
|
||||
if (Date.now() - startedAt > READY_HOLD_MS) {
|
||||
// same trade as above: show the skeleton, and say why it is empty
|
||||
contentVisible = true;
|
||||
showOverlay();
|
||||
return;
|
||||
}
|
||||
revealTimer = window.setTimeout(reveal, 20);
|
||||
};
|
||||
reveal();
|
||||
});
|
||||
|
||||
// the maps page embeds a full-bleed map: no padding, no scrolling
|
||||
@@ -225,15 +263,38 @@
|
||||
|
||||
<!-- Page-wide loading veil. Deliberately `pointer-events-none`: it is a status
|
||||
indicator, not a modal, so the nav and the search stay usable while a slow
|
||||
forecast is still on its way. -->
|
||||
forecast is still on its way. It is also always dismissable - Escape or the
|
||||
close button - because a veil nobody can get rid of is worse than no veil,
|
||||
and the page underneath is perfectly usable either way. -->
|
||||
<svelte:window onkeydown={onWindowKeydown} />
|
||||
|
||||
{#if loadingOverlay}
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-60 flex items-center justify-center bg-background/55 backdrop-blur-[2px]"
|
||||
in:fade={{ duration: 120 }}
|
||||
in:fade={{ duration: overlayFadesIn ? 120 : 0 }}
|
||||
out:fade={{ duration: 280 }}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="pointer-events-auto absolute top-3 right-3 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-lg transition-colors hover:bg-muted hover:text-foreground md:top-4 md:right-4"
|
||||
onclick={dismissOverlay}
|
||||
aria-label={m.page_loading_dismiss()}
|
||||
title={m.page_loading_dismiss()}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2.5 rounded-full border border-border bg-card px-4 py-2 shadow-lg"
|
||||
>
|
||||
@@ -297,10 +358,7 @@
|
||||
{:else}
|
||||
<!-- cap the content width on very large screens; the footer below
|
||||
gives the page its ending, so only modest bottom room is needed -->
|
||||
<div
|
||||
class="page-fade mx-auto w-full max-w-[1536px] flex-1 pb-24"
|
||||
class:page-fade-hidden={!contentVisible}
|
||||
>
|
||||
<div class="mx-auto w-full max-w-[1536px] flex-1 pb-24">
|
||||
{@render children()}
|
||||
</div>
|
||||
<!-- full-bleed footer inside the scroll area (its own inner max-w),
|
||||
|
||||
+22
-24
@@ -133,23 +133,6 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
/* Page cross-fade: driven from routes/+layout.svelte, revealed when the new
|
||||
page reports its data has arrived. */
|
||||
.page-fade {
|
||||
transition: opacity 320ms ease;
|
||||
}
|
||||
.page-fade-hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-fade {
|
||||
transition: none;
|
||||
}
|
||||
.page-fade-hidden {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Day switching ────────────────────────────────────────────────────────
|
||||
Only the three regions whose content depends on the selected day take part
|
||||
in the cross-fade. Everything else keeps its pixels: the `day-switch`
|
||||
@@ -210,15 +193,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme changes cross-fade the whole document in one pass (see
|
||||
routes/+layout.svelte). Doing it as a view transition instead of a blanket
|
||||
`* { transition }` matters: that blanket rule also stretched every
|
||||
/* Whole-document cross-fade, for both a page swap and a theme change (see
|
||||
routes/+layout.svelte). Doing the theme as a view transition instead of a
|
||||
blanket `* { transition }` matters: that blanket rule also stretched every
|
||||
component's own hover and focus transitions to 400ms for the duration of
|
||||
the switch, which read as lag on interactive controls. */
|
||||
::view-transition-old(root),
|
||||
the switch, which read as lag on interactive controls.
|
||||
|
||||
The default cross-fade is NOT opacity-neutral. Both snapshots are opaque
|
||||
and the browser fades one out while fading the other in, so halfway
|
||||
through, the two together cover only ~75% of the screen and the page
|
||||
background shows through everything: the whole screen visibly dips, which
|
||||
reads as a flash. Holding the outgoing snapshot at full opacity and fading
|
||||
only the incoming one in on top of it (it is painted above) keeps every
|
||||
frame fully covered. */
|
||||
::view-transition-old(root) {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 400ms;
|
||||
animation-timing-function: ease;
|
||||
animation: root-fade-in 400ms ease;
|
||||
}
|
||||
@keyframes root-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback for browsers without view transitions: fade the colours only. */
|
||||
|
||||
Reference in New Issue
Block a user