flashing fixed
This commit is contained in:
+10
-12
@@ -1,7 +1,7 @@
|
|||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isViewTransitionActive,
|
canStartViewTransition,
|
||||||
prefersReducedMotion,
|
prefersReducedMotion,
|
||||||
startViewTransition,
|
startViewTransition,
|
||||||
supportsViewTransitions
|
supportsViewTransitions
|
||||||
@@ -22,23 +22,21 @@ import {
|
|||||||
* page (see view-transition.ts).
|
* page (see view-transition.ts).
|
||||||
*/
|
*/
|
||||||
export async function runDayTransition(update: () => void): Promise<void> {
|
export async function runDayTransition(update: () => void): Promise<void> {
|
||||||
if (!supportsViewTransitions() || prefersReducedMotion() || isViewTransitionActive()) {
|
if (!canStartViewTransition()) {
|
||||||
update();
|
update();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = document.documentElement;
|
// Svelte applies the change on the next tick; the transition has to wait for
|
||||||
root.classList.add('day-switch');
|
// that before it snapshots the new state. `day-switch` scopes which regions
|
||||||
try {
|
// take part (see routes/layout.css) and is cleared for us when it ends.
|
||||||
// Svelte applies the change on the next tick; the transition has to wait
|
await startViewTransition(
|
||||||
// for that before it snapshots the new state.
|
async () => {
|
||||||
await startViewTransition(async () => {
|
|
||||||
update();
|
update();
|
||||||
await tick();
|
await tick();
|
||||||
});
|
},
|
||||||
} finally {
|
{ rootClass: 'day-switch' }
|
||||||
root.classList.remove('day-switch');
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,24 +1,40 @@
|
|||||||
/**
|
/**
|
||||||
* One view transition at a time.
|
* Central entry point for every view transition in the app.
|
||||||
*
|
*
|
||||||
* The browser runs exactly one: starting a second while the first is still on
|
* Two things it exists to get right.
|
||||||
* 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
|
* **One transition at a time, but only where it matters.** Starting a
|
||||||
* `goto` from `onMount`, so one click on a nav entry produces two (from the
|
* transition while another is running makes the browser skip the first, and
|
||||||
* root, three) navigations back to back - and the stub renders nothing, so
|
* what that looks like depends entirely on which phase the first is in:
|
||||||
* 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
|
* - *capturing* - its update callback has not resolved yet, so the screen is
|
||||||
* running keeps one continuous cross-fade across the whole chain.
|
* frozen on the outgoing snapshot. Skipping drops that snapshot on the spot
|
||||||
|
* and pops the half-updated live DOM into view: the whole-screen flash.
|
||||||
|
* This phase is common here, because navigation deliberately holds the
|
||||||
|
* callback open while the new page fetches, and `/` plus every bare
|
||||||
|
* `/weather/<view>/` page is a redirect stub that navigates again from
|
||||||
|
* `onMount` - one click, two or three navigations.
|
||||||
|
* - *animating* - the DOM is already in its final state and the pseudo
|
||||||
|
* elements are playing out. Skipping just finishes them early, landing on
|
||||||
|
* exactly the state they were heading for.
|
||||||
|
*
|
||||||
|
* So a new transition rides along inside the running one only while it is
|
||||||
|
* capturing; once it is animating, superseding it is the better answer (waiting
|
||||||
|
* would strand the new update under a stale snapshot until the animation ends).
|
||||||
|
*
|
||||||
|
* **A scoping class.** `rootClass` is set on `<html>` for the life of the
|
||||||
|
* transition, so the stylesheet can tell a page swap from a day switch and pin
|
||||||
|
* the parts that are identical on both sides (see routes/layout.css).
|
||||||
*/
|
*/
|
||||||
type UpdateCallback = () => void | Promise<void>;
|
type UpdateCallback = () => void | Promise<void>;
|
||||||
|
|
||||||
let active: Promise<void> | null = null;
|
interface Options {
|
||||||
|
/** Class set on `<html>` while the transition runs, for scoping CSS. */
|
||||||
|
rootClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set while a transition holds the screen frozen on the outgoing snapshot. */
|
||||||
|
let capturing: Promise<void> | null = null;
|
||||||
|
|
||||||
export const supportsViewTransitions = (): boolean =>
|
export const supportsViewTransitions = (): boolean =>
|
||||||
typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
|
typeof document !== 'undefined' && typeof document.startViewTransition === 'function';
|
||||||
@@ -26,20 +42,25 @@ export const supportsViewTransitions = (): boolean =>
|
|||||||
export const prefersReducedMotion = (): boolean =>
|
export const prefersReducedMotion = (): boolean =>
|
||||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
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;
|
* True while a transition is frozen on its outgoing snapshot - the phase in
|
||||||
|
* which starting a rival transition would flash the page.
|
||||||
|
*/
|
||||||
|
export const isViewTransitionCapturing = (): boolean => capturing !== null;
|
||||||
|
|
||||||
|
/** Whether `startViewTransition` would actually open one right now. */
|
||||||
|
export const canStartViewTransition = (): boolean =>
|
||||||
|
supportsViewTransitions() && !prefersReducedMotion() && !isViewTransitionCapturing();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs `update` inside a view transition, or straight away when one cannot (or
|
* Runs `update` inside a view transition, or straight away when one cannot (or
|
||||||
* must not) be started. Resolves once the transition has finished animating -
|
* 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.
|
* 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> {
|
export function startViewTransition(update: UpdateCallback, options: Options = {}): Promise<void> {
|
||||||
if (!supportsViewTransitions() || prefersReducedMotion() || active) {
|
const { rootClass } = options;
|
||||||
|
|
||||||
|
if (!canStartViewTransition()) {
|
||||||
return Promise.resolve(update()).then(
|
return Promise.resolve(update()).then(
|
||||||
() => {},
|
() => {},
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
@@ -48,25 +69,33 @@ export function startViewTransition(update: UpdateCallback): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (rootClass) root.classList.add(rootClass);
|
||||||
|
|
||||||
const transition = document.startViewTransition(update);
|
const transition = document.startViewTransition(update);
|
||||||
|
|
||||||
// A throw inside the callback rejects `updateCallbackDone` as well as
|
// A throw inside the callback rejects `updateCallbackDone` as well as
|
||||||
// `finished`. Nothing awaits the former, and an unhandled rejection there is
|
// `finished`. Nothing awaits the former, and an unhandled rejection there is
|
||||||
// what turns one bad render into an "Uncaught" error on the page.
|
// what turns one bad render into an "Uncaught" error on the page.
|
||||||
transition.updateCallbackDone.catch((error: unknown) => {
|
const captured = transition.updateCallbackDone.then(
|
||||||
console.error('view transition update failed', error);
|
() => {},
|
||||||
|
(error: unknown) => {
|
||||||
|
console.error('view transition update failed', error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
capturing = captured;
|
||||||
|
void captured.then(() => {
|
||||||
|
if (capturing === captured) capturing = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// A superseded transition rejects `finished`; the DOM is already up to date,
|
// 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.
|
// so that is not an error worth surfacing.
|
||||||
const done = transition.finished.then(
|
return transition.finished
|
||||||
() => {},
|
.then(
|
||||||
() => {}
|
() => {},
|
||||||
);
|
() => {}
|
||||||
active = done;
|
)
|
||||||
void done.then(() => {
|
.finally(() => {
|
||||||
if (active === done) active = null;
|
if (rootClass) root.classList.remove(rootClass);
|
||||||
});
|
});
|
||||||
|
|
||||||
return done;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-17
@@ -14,8 +14,7 @@
|
|||||||
import { storedTheme } from '$lib/stores/settings';
|
import { storedTheme } from '$lib/stores/settings';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isViewTransitionActive,
|
canStartViewTransition,
|
||||||
prefersReducedMotion,
|
|
||||||
startViewTransition,
|
startViewTransition,
|
||||||
supportsViewTransitions
|
supportsViewTransitions
|
||||||
} from '$lib/utils/view-transition';
|
} from '$lib/utils/view-transition';
|
||||||
@@ -54,7 +53,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (supportsViewTransitions() && !isViewTransitionActive()) {
|
if (canStartViewTransition()) {
|
||||||
// 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;
|
||||||
@@ -206,22 +205,25 @@
|
|||||||
else markPageReady();
|
else markPageReady();
|
||||||
|
|
||||||
// `startViewTransition` decides whether a transition is possible at all
|
// `startViewTransition` decides whether a transition is possible at all
|
||||||
// (support, reduced motion, one already on screen) 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 =
|
const underTransition = canStartViewTransition();
|
||||||
supportsViewTransitions() && !prefersReducedMotion() && !isViewTransitionActive();
|
|
||||||
|
|
||||||
return new Promise<void>((swap) => {
|
return new Promise<void>((swap) => {
|
||||||
void startViewTransition(async () => {
|
void startViewTransition(
|
||||||
// hand control back so SvelteKit swaps the DOM underneath the
|
async () => {
|
||||||
// frozen snapshot of the old page
|
// hand control back so SvelteKit swaps the DOM underneath the
|
||||||
swap();
|
// frozen snapshot of the old page
|
||||||
// A superseded navigation (a redirect, or a fast second click)
|
swap();
|
||||||
// rejects this promise; that is not an error worth surfacing,
|
// A superseded navigation (a redirect, or a fast second click)
|
||||||
// and leaving it unhandled shows up as "navigation aborted".
|
// rejects this promise; that is not an error worth surfacing,
|
||||||
await navigation.complete.catch(() => {});
|
// and leaving it unhandled shows up as "navigation aborted".
|
||||||
if (pending) await waitForContent(underTransition);
|
await navigation.complete.catch(() => {});
|
||||||
});
|
if (pending) await waitForContent(underTransition);
|
||||||
|
},
|
||||||
|
// pins the chrome that is the same on both sides (routes/layout.css)
|
||||||
|
{ rootClass: 'page-switch' }
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -315,7 +317,7 @@
|
|||||||
|
|
||||||
<div class="flex h-screen overflow-hidden bg-background text-foreground">
|
<div class="flex h-screen overflow-hidden bg-background text-foreground">
|
||||||
<!-- Desktop sidebar -->
|
<!-- Desktop sidebar -->
|
||||||
<div class="hidden h-full shrink-0 md:block">
|
<div class="sidebar-region hidden h-full shrink-0 md:block">
|
||||||
<WeatherNav collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
|
<WeatherNav collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+64
-31
@@ -159,25 +159,27 @@
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Same for the topbar: a captured region's snapshot is painted at its layout
|
/* Same for the topbar and the sidebar: a captured region's snapshot is
|
||||||
position, including the part normally scrolled up behind the chrome. */
|
painted at its layout position, including the part normally scrolled up
|
||||||
:root.day-switch .topbar {
|
behind the chrome. Both are also identical either side of a *page* swap,
|
||||||
|
so they are captured there too and pinned below - a region that does not
|
||||||
|
change should not be animated at all. */
|
||||||
|
:root.day-switch .topbar,
|
||||||
|
:root.page-switch .topbar {
|
||||||
view-transition-name: topbar;
|
view-transition-name: topbar;
|
||||||
}
|
}
|
||||||
::view-transition-group(topbar) {
|
::view-transition-group(topbar) {
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
}
|
}
|
||||||
|
:root.page-switch .sidebar-region {
|
||||||
::view-transition-old(day-table),
|
view-transition-name: sidebar;
|
||||||
::view-transition-new(day-table),
|
}
|
||||||
::view-transition-old(day-summary),
|
::view-transition-group(sidebar) {
|
||||||
::view-transition-new(day-summary),
|
z-index: 25;
|
||||||
::view-transition-old(day-charts),
|
|
||||||
::view-transition-new(day-charts) {
|
|
||||||
animation-duration: 420ms;
|
|
||||||
animation-timing-function: ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The root fade is the page swap's own; during a day switch the chrome must
|
||||||
|
not so much as flicker, so it is cancelled outright. */
|
||||||
:root.day-switch::view-transition-old(root),
|
:root.day-switch::view-transition-old(root),
|
||||||
:root.day-switch::view-transition-new(root) {
|
:root.day-switch::view-transition-new(root) {
|
||||||
animation: none;
|
animation: none;
|
||||||
@@ -188,35 +190,66 @@
|
|||||||
.day-region-summary,
|
.day-region-summary,
|
||||||
.day-region-charts,
|
.day-region-charts,
|
||||||
.daystrip,
|
.daystrip,
|
||||||
.topbar {
|
.topbar,
|
||||||
|
.sidebar-region {
|
||||||
view-transition-name: none;
|
view-transition-name: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Whole-document cross-fade, for both a page swap and a theme change (see
|
/* ── Cross-fades that do not dip ──────────────────────────────────────────
|
||||||
routes/+layout.svelte). Doing the theme as a view transition instead of a
|
The default cross-fade is NOT opacity-neutral: both snapshots are opaque
|
||||||
blanket `* { transition }` matters: that blanket rule also stretched every
|
and the browser fades one out while fading the other in, so at the
|
||||||
component's own hover and focus transitions to 400ms for the duration of
|
midpoint the pair covers only ~75% of the region and the page background
|
||||||
the switch, which read as lag on interactive controls.
|
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. Both snapshots are opaque
|
The only opacity-neutral pairing with normal blending is to hold the
|
||||||
and the browser fades one out while fading the other in, so halfway
|
outgoing snapshot at full opacity and fade the incoming one in on top of
|
||||||
through, the two together cover only ~75% of the screen and the page
|
it - it is painted above - so every frame stays fully covered. */
|
||||||
background shows through everything: the whole screen visibly dips, which
|
@keyframes vt-fade-in {
|
||||||
reads as a flash. Holding the outgoing snapshot at full opacity and fading
|
from {
|
||||||
only the incoming one in on top of it (it is painted above) keeps every
|
opacity: 0;
|
||||||
frame fully covered. */
|
}
|
||||||
::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: root-fade-in 400ms ease;
|
animation: vt-fade-in 400ms ease;
|
||||||
}
|
}
|
||||||
@keyframes root-fade-in {
|
::view-transition-new(day-table),
|
||||||
from {
|
::view-transition-new(day-summary),
|
||||||
opacity: 0;
|
::view-transition-new(day-charts) {
|
||||||
}
|
animation: vt-fade-in 420ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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.) */
|
||||||
|
::view-transition-old(topbar),
|
||||||
|
::view-transition-new(topbar),
|
||||||
|
::view-transition-old(sidebar),
|
||||||
|
::view-transition-new(sidebar),
|
||||||
|
::view-transition-old(daystrip),
|
||||||
|
::view-transition-new(daystrip) {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fallback for browsers without view transitions: fade the colours only. */
|
/* Fallback for browsers without view transitions: fade the colours only. */
|
||||||
|
|||||||
Reference in New Issue
Block a user