From 7e2495bd7579a16151ef875b571989492d190985 Mon Sep 17 00:00:00 2001
From: Fuwn <contact@fuwn.net>
Date: Tue, 19 May 2026 02:40:22 +0000
Subject: feat(nav): direction-aware view transitions, header excluded

Wires SvelteKit's onNavigate hook into document.startViewTransition,
with a callback that bypasses cleanly when the API is unavailable or
the user prefers reduced motion.

Direction is computed from navigation.from/to.pathname using the same
navigationOrder logic as the existing fly slide (forward through the
ordered routes, backward otherwise; entering /user is +1, leaving
-1). Sign is written to --vt-direction on :root before the transition
fires.

::view-transition-old(root) and -new(root) get explicit slide
keyframes that read --vt-direction as a sign multiplier on
translateX(200px), replacing the browser default crossfade with a
direction-aware page-flip that matches the prior feel.

.header carries view-transition-name: app-header so it is pulled out
of the root snapshot and treated as a shared element. Since the
header lives outside the {#key data.url} block and is the same DOM
element on both sides, its morph is a visual no-op: only the body
slides past it instead of the whole viewport.

Svelte's existing fly transition in Root.svelte still runs hidden
beneath the snapshot for browsers without View Transitions support,
acting as a graceful fallback.
---
 src/routes/+layout.svelte | 30 ++++++++++++++++++++++++++++++
 1 file changed, 30 insertions(+)

(limited to 'src/routes/+layout.svelte')

diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 8c36bb0f..e2cb4132 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -11,6 +11,7 @@ import HeadTitle from "$lib/Home/HeadTitle.svelte";
 import "../app.css";
 import { readable, type Readable } from "svelte/store";
 import { navigating } from "$app/stores";
+import { onNavigate } from "$app/navigation";
 import NotificationsProvider from "$lib/Notification/NotificationsProvider.svelte";
 import Root from "$lib/Home/Root.svelte";
 import root from "$lib/Utility/root";
@@ -90,6 +91,34 @@ $: way = data.url.includes("/user")
 
 $: if ($navigating) isMenuOpen = false;
 
+onNavigate((navigation) => {
+	if (!("startViewTransition" in document)) return;
+	if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
+
+	const fromPath = navigation.from?.url.pathname ?? "/";
+	const toPath = navigation.to?.url.pathname ?? "/";
+	const direction = toPath.includes("/user")
+		? 1
+		: fromPath.includes("/user")
+			? -1
+			: navigationOrder.indexOf(toPath) > navigationOrder.indexOf(fromPath)
+				? 1
+				: -1;
+
+	document.documentElement.style.setProperty("--vt-direction", String(direction));
+
+	return new Promise((resolve) => {
+		(
+			document as Document & {
+				startViewTransition: (cb: () => Promise<void>) => unknown;
+			}
+		).startViewTransition(async () => {
+			resolve();
+			await navigation.complete;
+		});
+	});
+});
+
 const handleScroll = () => {
 	const currentScrollPosition = window.scrollY;
 
@@ -437,6 +466,7 @@ $: {
     position: sticky;
     top: 1.25rem;
     background-color: var(--base0011-strong);
+    view-transition-name: app-header;
     transition: transform var(--duration-slow) var(--ease-out-quart);
   }
 
-- 
cgit v1.2.3

