From c4a9d47180543fe1690ee8fc12d9ced5271a8626 Mon Sep 17 00:00:00 2001 From: yohlo Date: Thu, 10 Sep 2026 22:21:16 -0700 Subject: [PATCH 1/5] slide animation --- bun.lock | 18 ++--- package.json | 8 +- public/styles.css | 79 +++++++++++++++++++ src/app/router.tsx | 18 +++++ src/app/routes/_authed/index.tsx | 12 ++- src/app/routes/_authed/profile.$playerId.tsx | 24 ++---- src/app/routes/_authed/teams.$teamId.tsx | 4 +- src/components/route-pending.tsx | 17 ++++ src/components/swipeable-tabs.tsx | 9 ++- .../badges/components/badge-showcase.tsx | 1 + src/features/core/components/header.tsx | 1 + src/features/core/components/layout.tsx | 28 ++++--- .../components/nav-link/styles.module.css | 5 -- .../core/components/route-transition.css | 11 --- src/features/core/utils/nav-direction.ts | 52 ++++++++++++ src/lib/tanstack-query/utils/ensure.ts | 10 +++ src/locales/de/messages.po | 2 +- src/locales/en/messages.po | 2 +- src/locales/es/messages.po | 2 +- src/locales/ja/messages.po | 2 +- 20 files changed, 232 insertions(+), 73 deletions(-) create mode 100644 src/components/route-pending.tsx delete mode 100644 src/features/core/components/route-transition.css create mode 100644 src/features/core/utils/nav-direction.ts diff --git a/bun.lock b/bun.lock index a5f208e..f655f59 100644 --- a/bun.lock +++ b/bun.lock @@ -31,8 +31,8 @@ "facehash": "^0.0.7", "framer-motion": "^12.23.12", "pocketbase": "^0.26.2", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-imask": "^7.6.1", "redaxios": "^0.5.1", "sonner": "^2.0.7", @@ -49,8 +49,8 @@ "@lingui/cli": "^6.5.0", "@lingui/vite-plugin": "^6.5.0", "@types/node": "^22.5.4", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.20", @@ -655,9 +655,9 @@ "@types/node": ["@types/node@22.19.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw=="], - "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], - "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="], @@ -1329,9 +1329,9 @@ "raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="], - "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], - "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], "react-imask": ["react-imask@7.6.1", "", { "dependencies": { "imask": "^7.6.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">=0.14.0" } }, "sha512-vLNfzcCz62Yzx/GRGh5tiCph9Gbh2cZu+Tz8OiO5it2eNuuhpA0DWhhSlOtVtSJ80+Bx+vFK5De8eQ9AmbkXzA=="], @@ -1397,7 +1397,7 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], "scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="], diff --git a/package.json b/package.json index c7cb483..aa9e2f1 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,8 @@ "facehash": "^0.0.7", "framer-motion": "^12.23.12", "pocketbase": "^0.26.2", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-imask": "^7.6.1", "redaxios": "^0.5.1", "sonner": "^2.0.7", @@ -61,8 +61,8 @@ "@lingui/cli": "^6.5.0", "@lingui/vite-plugin": "^6.5.0", "@types/node": "^22.5.4", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.20", diff --git a/public/styles.css b/public/styles.css index 1e22569..276f238 100644 --- a/public/styles.css +++ b/public/styles.css @@ -50,7 +50,86 @@ textarea, transform: scale(0.90) translateY(-4px); } +::view-transition-old(root), +::view-transition-new(root), +::view-transition-old(page), +::view-transition-new(page), +::view-transition-old(app-header), +::view-transition-new(app-header) { + animation-duration: 180ms; + animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); +} + +html[data-nav-direction="forward"]::view-transition-old(page) { + animation: 300ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-under-out; +} +html[data-nav-direction="forward"]::view-transition-new(page) { + animation: 300ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-over-in; +} +html[data-nav-direction="back"]::view-transition-old(page) { + animation: 300ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-over-out; + z-index: 1; +} +html[data-nav-direction="back"]::view-transition-new(page) { + animation: 300ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-under-in; +} + +html[data-nav-direction="forward"][data-header-vt="exit"]::view-transition-old(app-header) { + animation: 300ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-header-out; +} +html[data-nav-direction="back"][data-header-vt="exit"]::view-transition-old(app-header) { + animation: 300ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-over-out; +} +html[data-nav-direction="forward"][data-header-vt="enter"]::view-transition-new(app-header) { + animation: 300ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-over-in; +} +html[data-nav-direction="back"][data-header-vt="enter"]::view-transition-new(app-header) { + animation: 300ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-header-in; +} + +@keyframes flxn-page-over-in { + from { transform: translateX(100%); } +} +@keyframes flxn-page-over-out { + to { transform: translateX(100%); } +} +@keyframes flxn-page-under-out { + to { transform: translateX(-25%); opacity: 0.8; } +} +@keyframes flxn-page-under-in { + from { transform: translateX(-25%); opacity: 0.8; } +} + +@keyframes flxn-header-out { + 45% { opacity: 0; } + to { transform: translateX(-25%); opacity: 0; } +} +@keyframes flxn-header-in { + from { transform: translateX(-25%); opacity: 0; } +} + +@keyframes flxn-vt-fade-out { + to { opacity: 0; } +} +@keyframes flxn-vt-fade-in { + from { opacity: 0; } +} + @media (prefers-reduced-motion: reduce) { + ::view-transition-group(*), + ::view-transition-image-pair(*) { + animation: none !important; + } + + ::view-transition-old(*) { + animation: 120ms ease both flxn-vt-fade-out !important; + z-index: auto !important; + } + + ::view-transition-new(*) { + animation: 120ms ease both flxn-vt-fade-in !important; + } + .app, [data-drawer-level] { transition: none; diff --git a/src/app/router.tsx b/src/app/router.tsx index 655ea71..2ec5b70 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -3,6 +3,8 @@ import { createRouter as createTanStackRouter } from "@tanstack/react-router"; import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query"; import { routeTree } from "./routeTree.gen"; import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary"; +import RoutePending from "../components/route-pending"; +import { headerChange, navDirection } from "@/features/core/utils/nav-direction"; import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config"; export function getRouter() { @@ -30,6 +32,15 @@ export function getRouter() { }, defaultPreload: "intent", defaultPreloadStaleTime: 60_000, + defaultPendingMs: 100, + defaultPendingMinMs: 0, + defaultViewTransition: { + types: ({ fromLocation, toLocation }) => { + const direction = navDirection(fromLocation, toLocation); + return direction === "none" ? false : [direction]; + }, + }, + defaultPendingComponent: RoutePending, defaultErrorComponent: DefaultCatchBoundary, }); @@ -38,6 +49,13 @@ export function getRouter() { queryClient }) + if (typeof document !== "undefined") { + router.subscribe("onBeforeNavigate", ({ fromLocation, toLocation }) => { + document.documentElement.dataset.navDirection = navDirection(fromLocation, toLocation); + document.documentElement.dataset.headerVt = headerChange(fromLocation, toLocation); + }); + } + return router; } diff --git a/src/app/routes/_authed/index.tsx b/src/app/routes/_authed/index.tsx index 1faae50..4163fcc 100644 --- a/src/app/routes/_authed/index.tsx +++ b/src/app/routes/_authed/index.tsx @@ -1,7 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/queries"; import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament"; -import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; +import { getServerQueryData } from "@/lib/tanstack-query/utils/ensure"; +import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import StartedTournament from "@/features/tournaments/components/started-tournament"; import { Suspense } from "react"; import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton"; @@ -10,16 +11,13 @@ export const Route = createFileRoute("/_authed/")({ component: () => }> , - beforeLoad: async ({ context }) => { - const queryClient = context.queryClient; - const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current()) - - return { tournament } + beforeLoad: ({ context }) => { + prefetchServerQuery(context.queryClient, tournamentQueries.current()); }, loader: ({ context }) => ({ withPadding: false, header: { - title: context.tournament.name || "FLXN" + title: getServerQueryData(context.queryClient, tournamentQueries.current())?.name || "FLXN" } }), pendingComponent: () => diff --git a/src/app/routes/_authed/profile.$playerId.tsx b/src/app/routes/_authed/profile.$playerId.tsx index f9bf474..e2401d3 100644 --- a/src/app/routes/_authed/profile.$playerId.tsx +++ b/src/app/routes/_authed/profile.$playerId.tsx @@ -14,26 +14,12 @@ const searchSchema = z.object({ export const Route = createFileRoute("/_authed/profile/$playerId")({ validateSearch: searchSchema, - beforeLoad: async ({ params, context }) => { + beforeLoad: ({ params, context }) => { const { queryClient } = context; - await Promise.all([ - prefetchServerQuery( - queryClient, - playerQueries.details(params.playerId) - ), - prefetchServerQuery( - queryClient, - playerQueries.matches(params.playerId) - ), - prefetchServerQuery( - queryClient, - playerQueries.stats(params.playerId) - ), - prefetchServerQuery( - queryClient, - badgeQueries.playerBadges(params.playerId) - ), - ]); + prefetchServerQuery(queryClient, playerQueries.details(params.playerId)); + prefetchServerQuery(queryClient, playerQueries.matches(params.playerId)); + prefetchServerQuery(queryClient, playerQueries.stats(params.playerId)); + prefetchServerQuery(queryClient, badgeQueries.playerBadges(params.playerId)); }, loader: ({ params, context }) => ({ header: { diff --git a/src/app/routes/_authed/teams.$teamId.tsx b/src/app/routes/_authed/teams.$teamId.tsx index 5751be8..33d04b6 100644 --- a/src/app/routes/_authed/teams.$teamId.tsx +++ b/src/app/routes/_authed/teams.$teamId.tsx @@ -12,9 +12,9 @@ const searchSchema = z.object({ export const Route = createFileRoute("/_authed/teams/$teamId")({ validateSearch: searchSchema, - beforeLoad: async ({ params, context }) => { + beforeLoad: ({ params, context }) => { const { queryClient } = context; - await prefetchServerQuery(queryClient, teamQueries.details(params.teamId)); + prefetchServerQuery(queryClient, teamQueries.details(params.teamId)); }, loader: ({ params }) => ({ header: { diff --git a/src/components/route-pending.tsx b/src/components/route-pending.tsx new file mode 100644 index 0000000..d72b02f --- /dev/null +++ b/src/components/route-pending.tsx @@ -0,0 +1,17 @@ +import { Skeleton, Stack } from "@mantine/core"; + +const RoutePending = () => ( + + {Array.from({ length: 5 }).map((_, index) => ( + + ))} + +); + +export default RoutePending; diff --git a/src/components/swipeable-tabs.tsx b/src/components/swipeable-tabs.tsx index 03d6792..1430b70 100644 --- a/src/components/swipeable-tabs.tsx +++ b/src/components/swipeable-tabs.tsx @@ -6,7 +6,7 @@ import { ReactNode, useRef, useCallback, - useMemo, + startTransition, } from "react"; import { useRouter } from "@tanstack/react-router"; @@ -48,6 +48,11 @@ function SwipeableTabs({ }, [search?.tab, tabs, defaultTab]); const [activeTab, setActiveTab] = useState(getActiveTabFromUrl); + const [mountAll, setMountAll] = useState(false); + useEffect(() => { + const timeoutId = setTimeout(() => startTransition(() => setMountAll(true)), 400); + return () => clearTimeout(timeoutId); + }, []); const [stuck, setStuck] = useState(false); const sentinelRef = useRef(null); const [rootRef, setRootRef] = useState(null); @@ -225,7 +230,7 @@ function SwipeableTabs({ {tabs.map((tab, index) => ( - {tab.content} + {(mountAll || index === activeTab) ? tab.content : null} ))} diff --git a/src/features/badges/components/badge-showcase.tsx b/src/features/badges/components/badge-showcase.tsx index eab3042..6269e60 100644 --- a/src/features/badges/components/badge-showcase.tsx +++ b/src/features/badges/components/badge-showcase.tsx @@ -49,6 +49,7 @@ export const BadgeIcon = ({ badge, filled, size = 48 }: BadgeIconProps & { size? width={size} height={size} loading="lazy" + decoding="async" onError={() => setImageError(true)} style={{ objectFit: 'contain', diff --git a/src/features/core/components/header.tsx b/src/features/core/components/header.tsx index 5a62822..e5c3e47 100644 --- a/src/features/core/components/header.tsx +++ b/src/features/core/components/header.tsx @@ -60,6 +60,7 @@ const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig) alignItems: 'center', justifyContent: 'center', paddingTop: 'env(safe-area-inset-top, 0px)', + viewTransitionName: 'app-header', }} > { withBackButton && } diff --git a/src/features/core/components/layout.tsx b/src/features/core/components/layout.tsx index ab18cfb..fa6e017 100644 --- a/src/features/core/components/layout.tsx +++ b/src/features/core/components/layout.tsx @@ -1,23 +1,22 @@ import { AppShell, Box } from '@mantine/core'; import { PropsWithChildren } from 'react'; -import { useLocation } from '@tanstack/react-router'; import Header from './header'; import Navbar from './navbar'; import Pullable from './pullable'; import useVisualViewportSize from '../hooks/use-visual-viewport-size'; import useRouterConfig from '../hooks/use-router-config'; import Page from '@/components/page'; -import './route-transition.css'; const Layout: React.FC = ({ children }) => { const { header, withPadding, fullWidth } = useRouterConfig(); const viewport = useVisualViewportSize(); - const location = useLocation(); return ( = ({ children }) => { pos='relative' h='100%' mah='100%' - pb={{ base: 'calc(65px + env(safe-area-inset-bottom, 0px))', sm: 0 }} + pb={{ base: 'calc(73px + env(safe-area-inset-bottom, 0px))', sm: 0 }} px={{ base: 0.01, sm: 100, md: 200, lg: 300 }} maw='100dvw' style={{ overflow: 'hidden' }} > - - - + {/* pane box must be opaque and route-invariant or slides drift */} + + + {children} - - - + + + diff --git a/src/features/core/components/nav-link/styles.module.css b/src/features/core/components/nav-link/styles.module.css index 3472538..34e7210 100644 --- a/src/features/core/components/nav-link/styles.module.css +++ b/src/features/core/components/nav-link/styles.module.css @@ -11,11 +11,6 @@ -webkit-tap-highlight-color: transparent; } -.navLinkBox svg, -.navLinkBox p { - transition: color 150ms ease-out; -} - @media (prefers-reduced-motion: no-preference) { .navLinkBox { transition: transform 150ms cubic-bezier(0.32, 0.72, 0, 1); diff --git a/src/features/core/components/route-transition.css b/src/features/core/components/route-transition.css deleted file mode 100644 index fb54244..0000000 --- a/src/features/core/components/route-transition.css +++ /dev/null @@ -1,11 +0,0 @@ -@media (prefers-reduced-motion: no-preference) { - .flxn-route-enter { - animation: flxn-route-fade-in 180ms ease-out; - } -} - -@keyframes flxn-route-fade-in { - from { - opacity: 0; - } -} diff --git a/src/features/core/utils/nav-direction.ts b/src/features/core/utils/nav-direction.ts new file mode 100644 index 0000000..7739e39 --- /dev/null +++ b/src/features/core/utils/nav-direction.ts @@ -0,0 +1,52 @@ +import type { ParsedLocation } from "@tanstack/react-router"; + +export type NavDirection = "forward" | "back" | "none"; + +const tabRank = (pathname: string): number | null => { + if (pathname === "/") return 0; + if (pathname.startsWith("/stats")) return 1; + if (pathname.startsWith("/tournaments")) return 2; + if (pathname.startsWith("/profile") || pathname.startsWith("/settings")) return 3; + if (pathname.startsWith("/admin")) return 4; + return null; +}; + +const depth = (pathname: string) => pathname.split("/").filter(Boolean).length; + +// Keep in sync with route loaders that set header.collapsed +const headerHidden = (pathname: string) => { + const segs = pathname.split("/").filter(Boolean); + return segs.length === 2 && ["profile", "teams", "tournaments"].includes(segs[0]); +}; + +export function headerChange( + from: ParsedLocation | undefined, + to: ParsedLocation +): "exit" | "enter" | "none" { + if (!from) return "none"; + const fromHidden = headerHidden(from.pathname); + const toHidden = headerHidden(to.pathname); + if (fromHidden === toHidden) return "none"; + return toHidden ? "exit" : "enter"; +} + +export function navDirection( + from: ParsedLocation | undefined, + to: ParsedLocation +): NavDirection { + if (!from || from.pathname === to.pathname) return "none"; + + const fromRank = tabRank(from.pathname); + const toRank = tabRank(to.pathname); + if (fromRank !== null && toRank !== null && fromRank !== toRank) { + return toRank > fromRank ? "forward" : "back"; + } + + const fromIndex = from.state.__TSR_index; + const toIndex = to.state.__TSR_index; + if (Number.isFinite(fromIndex) && Number.isFinite(toIndex) && fromIndex !== toIndex) { + return toIndex > fromIndex ? "forward" : "back"; + } + + return depth(to.pathname) >= depth(from.pathname) ? "forward" : "back"; +} diff --git a/src/lib/tanstack-query/utils/ensure.ts b/src/lib/tanstack-query/utils/ensure.ts index b6e0fa3..07dcc6c 100644 --- a/src/lib/tanstack-query/utils/ensure.ts +++ b/src/lib/tanstack-query/utils/ensure.ts @@ -1,6 +1,16 @@ import { QueryClient, QueryKey } from "@tanstack/react-query"; import { ServerResult } from "../types"; +export function getServerQueryData( + queryClient: QueryClient, + query: { + queryKey: QueryKey; + queryFn: () => Promise>; + } +): TData | undefined { + return queryClient.getQueryData(query.queryKey); +} + export async function ensureServerQueryData( queryClient: QueryClient, query: { diff --git a/src/locales/de/messages.po b/src/locales/de/messages.po index d7d085e..deb0256 100644 --- a/src/locales/de/messages.po +++ b/src/locales/de/messages.po @@ -2091,7 +2091,7 @@ msgstr "Vorheriger Titel" msgid "Profile" msgstr "Profil" -#: src/features/badges/components/badge-showcase.tsx:301 +#: src/features/badges/components/badge-showcase.tsx:302 msgid "Progress " msgstr "Fortschritt " diff --git a/src/locales/en/messages.po b/src/locales/en/messages.po index c8f69ec..01f1dfd 100644 --- a/src/locales/en/messages.po +++ b/src/locales/en/messages.po @@ -2091,7 +2091,7 @@ msgstr "Previous track" msgid "Profile" msgstr "Profile" -#: src/features/badges/components/badge-showcase.tsx:301 +#: src/features/badges/components/badge-showcase.tsx:302 msgid "Progress " msgstr "Progress " diff --git a/src/locales/es/messages.po b/src/locales/es/messages.po index 0268021..e1e4804 100644 --- a/src/locales/es/messages.po +++ b/src/locales/es/messages.po @@ -2091,7 +2091,7 @@ msgstr "Pista anterior" msgid "Profile" msgstr "Perfil" -#: src/features/badges/components/badge-showcase.tsx:301 +#: src/features/badges/components/badge-showcase.tsx:302 msgid "Progress " msgstr "Progreso " diff --git a/src/locales/ja/messages.po b/src/locales/ja/messages.po index bda0837..8411f93 100644 --- a/src/locales/ja/messages.po +++ b/src/locales/ja/messages.po @@ -2091,7 +2091,7 @@ msgstr "前のトラック" msgid "Profile" msgstr "プロフィール" -#: src/features/badges/components/badge-showcase.tsx:301 +#: src/features/badges/components/badge-showcase.tsx:302 msgid "Progress " msgstr "進行状況 " -- 2.54.0 From 9ed3f7169ec30a0656b279f9e179591a951df4d9 Mon Sep 17 00:00:00 2001 From: yohlo Date: Fri, 11 Sep 2026 08:32:42 -0700 Subject: [PATCH 2/5] slide animation --- public/styles.css | 100 +++++++++++++++++------- src/app/routes/__root.tsx | 15 +++- src/components/swipeable-tabs.tsx | 13 +-- src/features/core/components/layout.tsx | 3 +- src/themes/aero/theme.css | 7 +- src/themes/clay/theme.css | 7 +- src/themes/deco/theme.css | 3 +- src/themes/gothic/theme.css | 3 +- src/themes/ocean/theme.css | 7 +- src/themes/quest/theme.css | 6 +- src/themes/sketch/theme.css | 13 ++- src/themes/terminal/theme.css | 5 +- src/themes/wireframe/theme.css | 3 +- 13 files changed, 120 insertions(+), 65 deletions(-) diff --git a/public/styles.css b/public/styles.css index 276f238..9d4fdd7 100644 --- a/public/styles.css +++ b/public/styles.css @@ -50,62 +50,97 @@ textarea, transform: scale(0.90) translateY(-4px); } +/* keep the page tappable mid-transition (rapid tab switching) */ +::view-transition { + pointer-events: none; +} + +/* Theme texture overlays (scanlines, rays, grain) paint ABOVE all content; a + real element with its own transition group keeps them on top and static + mid-transition — a body::before overlay sinks into the root snapshot and + blinks off the content for the duration of every transition. */ +.flxn-theme-overlay { + position: fixed; + pointer-events: none; + view-transition-name: theme-overlay; +} + +/* Root (wallpaper + tab bar) is identical across routes — swap it instantly + instead of crossfading; Safari's plus-lighter blend of the pair dims the + whole backdrop mid-fade. Same explicit-state treatment for the header and + theme overlay. */ ::view-transition-old(root), -::view-transition-new(root), -::view-transition-old(page), -::view-transition-new(page), ::view-transition-old(app-header), -::view-transition-new(app-header) { +::view-transition-old(theme-overlay) { + animation: none; + opacity: 0; +} +::view-transition-new(root), +::view-transition-new(app-header), +::view-transition-new(theme-overlay) { + animation: none; + opacity: 1; + mix-blend-mode: normal; +} +::view-transition-image-pair(root), +::view-transition-image-pair(app-header), +::view-transition-image-pair(theme-overlay), +::view-transition-image-pair(page) { + isolation: auto; +} + +::view-transition-old(page), +::view-transition-new(page) { animation-duration: 180ms; animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); + mix-blend-mode: normal; } +/* content pushes edge-to-edge over the static theme background; panes stay + transparent so the background never appears to move. The header is not in + these rules on purpose: it crossfades in place (its own group's default) — + sliding its chrome reads as a band appearing on continuous-bg themes */ html[data-nav-direction="forward"]::view-transition-old(page) { - animation: 300ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-under-out; + animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-out-left; } html[data-nav-direction="forward"]::view-transition-new(page) { - animation: 300ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-over-in; + animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-right; } html[data-nav-direction="back"]::view-transition-old(page) { - animation: 300ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-over-out; - z-index: 1; + animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-out-right; } html[data-nav-direction="back"]::view-transition-new(page) { - animation: 300ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-under-in; + animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-left; } +/* a header appearing or disappearing rides with its page instead of popping; + opacity overrides the pinned instant-swap defaults above */ html[data-nav-direction="forward"][data-header-vt="exit"]::view-transition-old(app-header) { - animation: 300ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-header-out; + animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-out-left; + opacity: 1; } html[data-nav-direction="back"][data-header-vt="exit"]::view-transition-old(app-header) { - animation: 300ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-over-out; + animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-out-right; + opacity: 1; } html[data-nav-direction="forward"][data-header-vt="enter"]::view-transition-new(app-header) { - animation: 300ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-over-in; + animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-right; } html[data-nav-direction="back"][data-header-vt="enter"]::view-transition-new(app-header) { - animation: 300ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-header-in; + animation: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-left; } -@keyframes flxn-page-over-in { +@keyframes flxn-page-out-left { + to { transform: translateX(-100%); } +} +@keyframes flxn-page-in-right { from { transform: translateX(100%); } } -@keyframes flxn-page-over-out { +@keyframes flxn-page-out-right { to { transform: translateX(100%); } } -@keyframes flxn-page-under-out { - to { transform: translateX(-25%); opacity: 0.8; } -} -@keyframes flxn-page-under-in { - from { transform: translateX(-25%); opacity: 0.8; } -} - -@keyframes flxn-header-out { - 45% { opacity: 0; } - to { transform: translateX(-25%); opacity: 0; } -} -@keyframes flxn-header-in { - from { transform: translateX(-25%); opacity: 0; } +@keyframes flxn-page-in-left { + from { transform: translateX(-100%); } } @keyframes flxn-vt-fade-out { @@ -130,6 +165,15 @@ html[data-nav-direction="back"][data-header-vt="enter"]::view-transition-new(app animation: 120ms ease both flxn-vt-fade-in !important; } + ::view-transition-old(root), + ::view-transition-old(app-header), + ::view-transition-old(theme-overlay), + ::view-transition-new(root), + ::view-transition-new(app-header), + ::view-transition-new(theme-overlay) { + animation: none !important; + } + .app, [data-drawer-level] { transition: none; diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index a81eb22..677f57d 100644 --- a/src/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -183,7 +183,19 @@ function RootComponent() { ensureSuperTokensFrontend(); initTelemetry(router); if (import.meta.env.PROD && 'serviceWorker' in navigator) { - navigator.serviceWorker.register('/sw.js').catch(() => track('sw_register_failed')); + // first install claims the page too; only reload on real updates + const hadController = !!navigator.serviceWorker.controller; + let reloading = false; + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (!hadController || reloading) return; + reloading = true; + window.location.reload(); + }); + navigator.serviceWorker.register('/sw.js').then((registration) => { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') registration.update().catch(() => {}); + }); + }).catch(() => track('sw_register_failed')); } }, [router]); @@ -243,6 +255,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { }} >
{children}
+