last fade attempt
This commit is contained in:
@@ -7,22 +7,31 @@
|
|||||||
* history entry or re-runs a load - the back button still means "the page
|
* history entry or re-runs a load - the back button still means "the page
|
||||||
* before", not "the previous day I clicked".
|
* before", not "the previous day I clicked".
|
||||||
*
|
*
|
||||||
* Callers must pass the URL *untracked* (`get(page).url`, not `$page.url`).
|
* The base is `location`, deliberately not `page.url`. Shallow routing does not
|
||||||
* Reading it reactively inside the same effect that writes it creates a loop:
|
* republish the URL: `replaceState` writes the history entry (and files the
|
||||||
* replaceState publishes a new URL, the effect re-runs, writes again - which
|
* *previous* `page.url` in it, so a popstate can restore it) but leaves
|
||||||
* Svelte eventually kills with `effect_update_depth_exceeded`, hanging the page.
|
* `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 { browser } from '$app/environment';
|
||||||
import { replaceState } from '$app/navigation';
|
import { replaceState } from '$app/navigation';
|
||||||
|
|
||||||
export function syncSearchParams(url: URL, updates: Record<string, string | null>): void {
|
export function syncSearchParams(updates: Record<string, string | null>): void {
|
||||||
if (!browser) return;
|
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)) {
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
if (value == null || value === '') next.searchParams.delete(key);
|
if (value == null || value === '') next.searchParams.delete(key);
|
||||||
else next.searchParams.set(key, value);
|
else next.searchParams.set(key, value);
|
||||||
}
|
}
|
||||||
if (next.href === url.href) return;
|
if (next.href === current.href) return;
|
||||||
try {
|
try {
|
||||||
replaceState(next, {});
|
replaceState(next, {});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ type UpdateCallback = () => void | Promise<void>;
|
|||||||
interface Options {
|
interface Options {
|
||||||
/** Class set on `<html>` while the transition runs, for scoping CSS. */
|
/** Class set on `<html>` while the transition runs, for scoping CSS. */
|
||||||
rootClass?: string;
|
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. */
|
/** 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.
|
* or as soon as the update is done, when it ran on its own.
|
||||||
*/
|
*/
|
||||||
export function startViewTransition(update: UpdateCallback, options: Options = {}): Promise<void> {
|
export function startViewTransition(update: UpdateCallback, options: Options = {}): Promise<void> {
|
||||||
const { rootClass } = options;
|
const { rootClass, enabled = true } = options;
|
||||||
|
|
||||||
if (!canStartViewTransition()) {
|
if (!enabled || !canStartViewTransition()) {
|
||||||
return Promise.resolve(update()).then(
|
return Promise.resolve(update()).then(
|
||||||
() => {},
|
() => {},
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canStartViewTransition()) {
|
if (canStartViewTransition() && !onMapsPage()) {
|
||||||
// one cross-fade of the whole document; component transitions untouched
|
// one cross-fade of the whole document; component transitions untouched
|
||||||
void startViewTransition(paint);
|
void startViewTransition(paint);
|
||||||
return;
|
return;
|
||||||
@@ -103,6 +103,14 @@
|
|||||||
// own way out rather than sitting on top of an error message forever.
|
// own way out rather than sitting on top of an error message forever.
|
||||||
const OVERLAY_CEILING_MS = 15000;
|
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 loadingOverlay = $state(false);
|
||||||
let overlayCeilingTimer = 0;
|
let overlayCeilingTimer = 0;
|
||||||
// A view transition freezes the page, so a Svelte in-transition started under
|
// 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
|
// `startViewTransition` decides whether a transition is possible at all
|
||||||
// (support, reduced motion, one already capturing) and runs the update
|
// (support, reduced motion, one already capturing) and runs the update
|
||||||
// inline when it is not - so there is exactly one path from here down.
|
// 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<void>((swap) => {
|
return new Promise<void>((swap) => {
|
||||||
void startViewTransition(
|
void startViewTransition(
|
||||||
@@ -224,7 +234,7 @@
|
|||||||
if (pending) await waitForContent(underTransition);
|
if (pending) await waitForContent(underTransition);
|
||||||
},
|
},
|
||||||
// pins the chrome that is the same on both sides (routes/layout.css)
|
// pins the chrome that is the same on both sides (routes/layout.css)
|
||||||
{ rootClass: 'page-switch' }
|
{ rootClass: 'page-switch', enabled: underTransition }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+37
-30
@@ -197,56 +197,63 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Cross-fades that do not dip ──────────────────────────────────────────
|
/* ── Cross-fades that do not dip ──────────────────────────────────────────
|
||||||
The default cross-fade is NOT opacity-neutral: both snapshots are opaque
|
The default cross-fade is NOT opacity-neutral: coverage of two stacked
|
||||||
and the browser fades one out while fading the other in, so at the
|
layers is `new + old * (1 - new)`, so at the midpoint the pair covers only
|
||||||
midpoint the pair covers only ~75% of the region and the page background
|
~75% of the region and the page background shows through both. That dip is
|
||||||
shows through both. That dip is the flash - over the whole viewport for a
|
the flash - over the whole viewport for a page swap, and over the table,
|
||||||
page swap, and over the table, summary and charts (which between them are
|
summary and charts (between them the whole content column) for a day
|
||||||
the whole content column) for a day switch.
|
switch.
|
||||||
|
|
||||||
The only opacity-neutral pairing with normal blending is to hold the
|
Two ways out, and which one applies depends on whether the *incoming*
|
||||||
outgoing snapshot at full opacity and fade the incoming one in on top of
|
snapshot is opaque. Holding the outgoing one at full opacity fixes the
|
||||||
it - it is painted above - so every frame stays fully covered. */
|
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 {
|
@keyframes vt-fade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
::view-transition-old(root) {
|
||||||
::view-transition-old(root),
|
|
||||||
::view-transition-old(day-table),
|
|
||||||
::view-transition-old(day-summary),
|
|
||||||
::view-transition-old(day-charts) {
|
|
||||||
animation: none;
|
animation: none;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
::view-transition-new(root) {
|
::view-transition-new(root) {
|
||||||
animation: vt-fade-in 400ms ease;
|
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-new(day-table),
|
||||||
|
::view-transition-old(day-summary),
|
||||||
::view-transition-new(day-summary),
|
::view-transition-new(day-summary),
|
||||||
|
::view-transition-old(day-charts),
|
||||||
::view-transition-new(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*
|
/* Chrome captured only so the fading regions cannot paint over it. The strip
|
||||||
would keep showing its old tail below the new content for the whole
|
does change - the selected day moves - but that change belongs to the
|
||||||
transition. Clipping to the group - which animates between the two sizes -
|
regions' cross-fade, not to the strip, so it swaps outright: the outgoing
|
||||||
turns that into the shrink it actually is. */
|
snapshot is dropped rather than held, because the selected cell is a
|
||||||
::view-transition-group(day-table),
|
translucent `bg-primary/10` tint and anything left underneath shows
|
||||||
::view-transition-group(day-summary),
|
through it as a second, doubled label. */
|
||||||
::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.) */
|
|
||||||
::view-transition-old(topbar),
|
::view-transition-old(topbar),
|
||||||
::view-transition-new(topbar),
|
|
||||||
::view-transition-old(sidebar),
|
::view-transition-old(sidebar),
|
||||||
|
::view-transition-old(daystrip) {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
::view-transition-new(topbar),
|
||||||
::view-transition-new(sidebar),
|
::view-transition-new(sidebar),
|
||||||
::view-transition-old(daystrip),
|
|
||||||
::view-transition-new(daystrip) {
|
::view-transition-new(daystrip) {
|
||||||
animation: none;
|
animation: none;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const model = params.models?.[0];
|
const model = params.models?.[0];
|
||||||
if (!mounted || !model) return;
|
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
|
// components persist across refetches; entries are null while unmounted
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
const models = params.models;
|
const models = params.models;
|
||||||
const vars = params.hourly;
|
const vars = params.hourly;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
syncSearchParams(get(page).url, {
|
syncSearchParams({
|
||||||
models: listUnlessDefault(models, DEFAULT_MODELS),
|
models: listUnlessDefault(models, DEFAULT_MODELS),
|
||||||
vars: listUnlessDefault(vars, DEFAULT_VARS)
|
vars: listUnlessDefault(vars, DEFAULT_VARS)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -273,7 +273,7 @@
|
|||||||
const isToday = fetchedDaily
|
const isToday = fetchedDaily
|
||||||
? dayKey === formatZoned(new Date(), fetchedDaily.timezone, 'yyyy-MM-dd')
|
? dayKey === formatZoned(new Date(), fetchedDaily.timezone, 'yyyy-MM-dd')
|
||||||
: false;
|
: false;
|
||||||
syncSearchParams(get(page).url, {
|
syncSearchParams({
|
||||||
day: isToday ? null : dayKey,
|
day: isToday ? null : dayKey,
|
||||||
model: unlessDefault(model, 'best_match')
|
model: unlessDefault(model, 'best_match')
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user