This commit is contained in:
Vincent van der Wal
2026-08-06 19:11:11 +02:00
parent 025623aaa6
commit f4a02a208c
21 changed files with 336 additions and 78 deletions
+92 -16
View File
@@ -13,9 +13,8 @@
import Header from '$lib/components/navigation/header.svelte';
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
import favicon from '$lib/assets/favicon.svg';
import { routePath } from '$lib/i18n';
import * as m from '$lib/paraglide/messages';
import './layout.css';
@@ -63,18 +62,46 @@
// ── Page cross-fade ───────────────────────────────────────────────────────
// A real cross-fade needs the outgoing page still on screen while the
// incoming one appears - so the view transition is opened at navigation and
// deliberately held open until the new page reports that its data has landed.
// The browser keeps showing the old snapshot for that whole time (rather than
// flashing a skeleton), then fades it into the finished page.
// held open for a short grace period, waiting for the new page to report that
// its data has landed. A cached or fast response lands inside that window and
// the old page fades straight into the finished one, no skeleton in between.
//
// The wait is capped: past the ceiling we cross-fade into whatever is on
// screen instead of freezing the UI on a slow network.
const READY_CEILING_MS = 2200;
// Past the grace period the wait gives up and the loading overlay takes over:
// holding the old page frozen any longer looks like a dead click, and the
// overlay is the honest answer - something is happening, it just isn't here
// 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.
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;
function showOverlay(): void {
loadingOverlay = true;
clearTimeout(overlayCeilingTimer);
overlayCeilingTimer = window.setTimeout(() => (loadingOverlay = false), OVERLAY_CEILING_MS);
}
function hideOverlay(): void {
clearTimeout(overlayHoldTimer);
clearTimeout(overlayCeilingTimer);
loadingOverlay = false;
}
/** 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;
@@ -89,22 +116,36 @@
'/weather/historical/[location]'
]);
/** Resolves once the freshly mounted page has its data, or at the ceiling. */
/**
* Resolves once the freshly mounted page has its data, or once the grace
* 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> {
const deadline = Date.now() + READY_CEILING_MS;
const deadline = Date.now() + READY_HOLD_MS;
while (!get(pageContentReady) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 40));
await new Promise((resolve) => setTimeout(resolve, 20));
}
if (!get(pageContentReady)) showOverlay();
// one more frame so the page paints its data before the snapshot is taken
await tick();
}
onNavigate((navigation) => {
if (reducedMotion()) return;
const loadsData = DATA_ROUTES.has(navigation.to?.route?.id ?? '');
if (loadsData) markPageLoading();
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;
}
if (typeof document.startViewTransition === 'function') {
return new Promise<void>((swap) => {
document.startViewTransition(async () => {
@@ -142,11 +183,17 @@
}
const startedAt = Date.now();
const reveal = () => {
if (get(pageContentReady) || Date.now() - startedAt > READY_CEILING_MS) {
if (get(pageContentReady)) {
contentVisible = true;
return;
}
revealTimer = window.setTimeout(reveal, 60);
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();
});
@@ -171,11 +218,40 @@
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<!-- the icon itself lives in app.html, so the SPA fallback carries it too -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</svelte:head>
<!-- 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. -->
{#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 }}
out:fade={{ duration: 280 }}
role="status"
aria-live="polite"
>
<div
class="flex items-center gap-2.5 rounded-full border border-border bg-card px-4 py-2 shadow-lg"
>
<svg
class="h-4 w-4 animate-spin text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span class="text-sm font-semibold">{m.page_loading()}</span>
</div>
</div>
{/if}
<div class="flex h-screen overflow-hidden bg-background text-foreground">
<!-- Desktop sidebar -->
<div class="hidden h-full shrink-0 md:block">