From d0f94094ca3ef5a16505ba174db4e8316fcfcb77 Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Thu, 6 Aug 2026 21:37:28 +0200 Subject: [PATCH] last fade attempt --- src/lib/utils/url-state.ts | 23 +++++-- src/lib/utils/view-transition.ts | 10 ++- src/routes/+layout.svelte | 16 ++++- src/routes/layout.css | 67 ++++++++++--------- .../weather/14-day/[location]/+page.svelte | 2 +- .../weather/compare/[location]/+page.svelte | 2 +- .../weather/week/[location]/+page.svelte | 2 +- 7 files changed, 77 insertions(+), 45 deletions(-) diff --git a/src/lib/utils/url-state.ts b/src/lib/utils/url-state.ts index 09ecd1f..f09f19d 100644 --- a/src/lib/utils/url-state.ts +++ b/src/lib/utils/url-state.ts @@ -7,22 +7,31 @@ * history entry or re-runs a load - the back button still means "the page * before", not "the previous day I clicked". * - * Callers must pass the URL *untracked* (`get(page).url`, not `$page.url`). - * Reading it reactively inside the same effect that writes it creates a loop: - * replaceState publishes a new URL, the effect re-runs, writes again - which - * Svelte eventually kills with `effect_update_depth_exceeded`, hanging the page. + * The base is `location`, deliberately not `page.url`. Shallow routing does not + * republish the URL: `replaceState` writes the history entry (and files the + * *previous* `page.url` in it, so a popstate can restore it) but leaves + * `page.url` on the last navigated URL. Diffing against that stale value is + * wrong in exactly one direction - clearing a parameter. Opening a day writes + * `?day=`, `page.url` still has none, so asking to remove it produces a URL + * identical to the stale one, the write is skipped as a no-op, and the + * parameter stays in the address bar for good. + * + * Reading `location` rather than a passed-in URL also removes the old trap that + * callers had to pass it untracked: an effect that both read `$page.url` and + * wrote to it looped until `effect_update_depth_exceeded` hung the page. */ import { browser } from '$app/environment'; import { replaceState } from '$app/navigation'; -export function syncSearchParams(url: URL, updates: Record): void { +export function syncSearchParams(updates: Record): void { if (!browser) return; - const next = new URL(url); + const current = new URL(window.location.href); + const next = new URL(current); for (const [key, value] of Object.entries(updates)) { if (value == null || value === '') next.searchParams.delete(key); else next.searchParams.set(key, value); } - if (next.href === url.href) return; + if (next.href === current.href) return; try { replaceState(next, {}); } catch { diff --git a/src/lib/utils/view-transition.ts b/src/lib/utils/view-transition.ts index fef3238..6045f3b 100644 --- a/src/lib/utils/view-transition.ts +++ b/src/lib/utils/view-transition.ts @@ -31,6 +31,12 @@ type UpdateCallback = () => void | Promise; interface Options { /** Class set on `` while the transition runs, for scoping CSS. */ rootClass?: string; + /** + * Set false to run the update without a transition. For content the browser + * does not paint into a snapshot - a cross-origin iframe - where animating + * means animating a hole rather than a cross-fade. + */ + enabled?: boolean; } /** Set while a transition holds the screen frozen on the outgoing snapshot. */ @@ -58,9 +64,9 @@ export const canStartViewTransition = (): boolean => * or as soon as the update is done, when it ran on its own. */ export function startViewTransition(update: UpdateCallback, options: Options = {}): Promise { - const { rootClass } = options; + const { rootClass, enabled = true } = options; - if (!canStartViewTransition()) { + if (!enabled || !canStartViewTransition()) { return Promise.resolve(update()).then( () => {}, (error: unknown) => { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index fb898d5..c604f33 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -53,7 +53,7 @@ return; } - if (canStartViewTransition()) { + if (canStartViewTransition() && !onMapsPage()) { // one cross-fade of the whole document; component transitions untouched void startViewTransition(paint); return; @@ -103,6 +103,14 @@ // own way out rather than sitting on top of an error message forever. const OVERLAY_CEILING_MS = 15000; + // The map is a cross-origin iframe, and a browser does not paint one into a + // view transition snapshot. Any transition with the maps page on either side + // animates a hole where the map is: leaving it, the map drops out at frame one + // and that blank sits under the incoming page for the whole run. Swapping + // outright is the honest answer - there is nothing here to cross-fade. + const MAPS_ROUTE = '/weather/maps'; + const onMapsPage = () => routePath(get(page).url.pathname).startsWith(MAPS_ROUTE); + let loadingOverlay = $state(false); let overlayCeilingTimer = 0; // A view transition freezes the page, so a Svelte in-transition started under @@ -209,7 +217,9 @@ // `startViewTransition` decides whether a transition is possible at all // (support, reduced motion, one already capturing) and runs the update // inline when it is not - so there is exactly one path from here down. - const underTransition = canStartViewTransition(); + const touchesMap = + navigation.from?.route?.id === MAPS_ROUTE || navigation.to?.route?.id === MAPS_ROUTE; + const underTransition = canStartViewTransition() && !touchesMap; return new Promise((swap) => { void startViewTransition( @@ -224,7 +234,7 @@ if (pending) await waitForContent(underTransition); }, // pins the chrome that is the same on both sides (routes/layout.css) - { rootClass: 'page-switch' } + { rootClass: 'page-switch', enabled: underTransition } ); }); }); diff --git a/src/routes/layout.css b/src/routes/layout.css index 672b170..967201a 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -197,56 +197,63 @@ } /* ── Cross-fades that do not dip ────────────────────────────────────────── - The default cross-fade is NOT opacity-neutral: both snapshots are opaque - and the browser fades one out while fading the other in, so at the - midpoint the pair covers only ~75% of the region and the page background - shows through both. That dip is the flash - over the whole viewport for a - page swap, and over the table, summary and charts (which between them are - the whole content column) for a day switch. + The default cross-fade is NOT opacity-neutral: coverage of two stacked + layers is `new + old * (1 - new)`, so at the midpoint the pair covers only + ~75% of the region and the page background shows through both. That dip is + the flash - over the whole viewport for a page swap, and over the table, + summary and charts (between them the whole content column) for a day + switch. - The only opacity-neutral pairing with normal blending is to hold the - outgoing snapshot at full opacity and fade the incoming one in on top of - it - it is painted above - so every frame stays fully covered. */ + Two ways out, and which one applies depends on whether the *incoming* + snapshot is opaque. Holding the outgoing one at full opacity fixes the + coverage arithmetic, but a translucent incoming snapshot then reads + straight through it and the old content lingers as a ghost. */ + + /* The document background is opaque, so the whole-viewport swap can simply + hold the outgoing snapshot until the pseudo elements are torn down. */ @keyframes vt-fade-in { from { opacity: 0; } } - - ::view-transition-old(root), - ::view-transition-old(day-table), - ::view-transition-old(day-summary), - ::view-transition-old(day-charts) { + ::view-transition-old(root) { animation: none; opacity: 1; } ::view-transition-new(root) { animation: vt-fade-in 400ms ease; } + + /* The day regions are not opaque - they are cards with gaps and headings + between them - so they keep a real cross-fade and fix the dip the other + way: `plus-lighter` makes the two halves sum to exactly the original + wherever they agree, which is most of the region (the card backgrounds are + the same on both days; only the readings differ). */ + ::view-transition-old(day-table), ::view-transition-new(day-table), + ::view-transition-old(day-summary), ::view-transition-new(day-summary), + ::view-transition-old(day-charts), ::view-transition-new(day-charts) { - animation: vt-fade-in 420ms ease; + animation-duration: 420ms; + animation-timing-function: ease; + mix-blend-mode: plus-lighter; } - /* The outgoing snapshot no longer fades, so a region that gets *shorter* - would keep showing its old tail below the new content for the whole - transition. Clipping to the group - which animates between the two sizes - - turns that into the shrink it actually is. */ - ::view-transition-group(day-table), - ::view-transition-group(day-summary), - ::view-transition-group(day-charts) { - overflow: clip; - } - - /* Chrome that is identical on both sides of the swap: pinned, never faded. - (Whole-document theme changes are a `root` transition with no page-switch - class, so they still cross-fade the chrome along with everything else.) */ + /* Chrome captured only so the fading regions cannot paint over it. The strip + does change - the selected day moves - but that change belongs to the + regions' cross-fade, not to the strip, so it swaps outright: the outgoing + snapshot is dropped rather than held, because the selected cell is a + translucent `bg-primary/10` tint and anything left underneath shows + through it as a second, doubled label. */ ::view-transition-old(topbar), - ::view-transition-new(topbar), ::view-transition-old(sidebar), + ::view-transition-old(daystrip) { + animation: none; + opacity: 0; + } + ::view-transition-new(topbar), ::view-transition-new(sidebar), - ::view-transition-old(daystrip), ::view-transition-new(daystrip) { animation: none; opacity: 1; diff --git a/src/routes/weather/14-day/[location]/+page.svelte b/src/routes/weather/14-day/[location]/+page.svelte index 743053a..3235980 100644 --- a/src/routes/weather/14-day/[location]/+page.svelte +++ b/src/routes/weather/14-day/[location]/+page.svelte @@ -98,7 +98,7 @@ $effect(() => { const model = params.models?.[0]; if (!mounted || !model) return; - syncSearchParams(get(page).url, { model: unlessDefault(model, DEFAULT_MODEL) }); + syncSearchParams({ model: unlessDefault(model, DEFAULT_MODEL) }); }); // components persist across refetches; entries are null while unmounted diff --git a/src/routes/weather/compare/[location]/+page.svelte b/src/routes/weather/compare/[location]/+page.svelte index 4300a0b..062ffc5 100644 --- a/src/routes/weather/compare/[location]/+page.svelte +++ b/src/routes/weather/compare/[location]/+page.svelte @@ -134,7 +134,7 @@ const models = params.models; const vars = params.hourly; if (!mounted) return; - syncSearchParams(get(page).url, { + syncSearchParams({ models: listUnlessDefault(models, DEFAULT_MODELS), vars: listUnlessDefault(vars, DEFAULT_VARS) }); diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index ee9176c..8599b9d 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -273,7 +273,7 @@ const isToday = fetchedDaily ? dayKey === formatZoned(new Date(), fetchedDaily.timezone, 'yyyy-MM-dd') : false; - syncSearchParams(get(page).url, { + syncSearchParams({ day: isToday ? null : dayKey, model: unlessDefault(model, 'best_match') });