remove slide animation, use pill instead
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 38s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m11s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m31s

This commit is contained in:
2026-09-14 00:53:24 -07:00
parent a754fc0282
commit 9febb8bb60
11 changed files with 156 additions and 219 deletions
-14
View File
@@ -4,7 +4,6 @@ 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() {
@@ -34,12 +33,6 @@ export function getRouter() {
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,
});
@@ -49,13 +42,6 @@ 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;
}
+1 -108
View File
@@ -50,120 +50,13 @@ textarea,
transform: scale(0.90) translateY(-4px);
}
/* keep the page tappable mid-transition (rapid tab switching) */
::view-transition {
pointer-events: none;
}
/* theme overlays must be this element, not body::before, or they blink off
during transitions */
/* themes paint their full-page texture through this element */
.flxn-theme-overlay {
position: fixed;
pointer-events: none;
view-transition-name: theme-overlay;
}
/* pinned states, not 0ms durations: Safari skips zero-duration VT animations
and blends both snapshots */
::view-transition-old(root),
::view-transition-old(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;
}
html[data-nav-direction="forward"]::view-transition-old(page) {
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: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-right;
}
html[data-nav-direction="back"]::view-transition-old(page) {
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: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-left;
}
html[data-nav-direction="forward"][data-header-vt="exit"]::view-transition-old(app-header) {
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: 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: 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: 250ms cubic-bezier(0.32, 0.72, 0, 1) both flxn-page-in-left;
}
@keyframes flxn-page-out-left {
to { transform: translateX(-100%); }
}
@keyframes flxn-page-in-right {
from { transform: translateX(100%); }
}
@keyframes flxn-page-out-right {
to { transform: translateX(100%); }
}
@keyframes flxn-page-in-left {
from { transform: translateX(-100%); }
}
@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;
}
::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;
-1
View File
@@ -60,7 +60,6 @@ const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig)
alignItems: 'center',
justifyContent: 'center',
paddingTop: 'env(safe-area-inset-top, 0px)',
viewTransitionName: 'app-header',
}}
>
{ withBackButton && <BackButton /> }
+2 -12
View File
@@ -1,4 +1,4 @@
import { AppShell, Box } from '@mantine/core';
import { AppShell } from '@mantine/core';
import { PropsWithChildren } from 'react';
import Header from './header';
import Navbar from './navbar';
@@ -15,7 +15,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
<AppShell
id='app-shell'
layout='alt'
// must snap: view transitions capture AppShell's collapse mid-animation
// 0 = header collapse is instant; Mantine's default animates main's padding
transitionDuration={0}
header={{
// Old installs froze black-translucent at add-to-home-screen time, so
@@ -51,22 +51,12 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
maw='100dvw'
style={{ overflow: 'hidden' }}
>
{/* pane box must be route-invariant or slides drift */}
<Box
style={{
viewTransitionName: 'page',
marginTop: 'calc(-1 * var(--app-shell-header-offset, 0px))',
paddingTop: 'var(--app-shell-header-offset, 0px)',
height: 'calc(100% + var(--app-shell-header-offset, 0px))',
}}
>
<Pullable>
<Page noPadding={!withPadding} fullWidth={fullWidth}>
{children}
</Page>
</Pullable>
</Box>
</AppShell.Main>
<Navbar />
<AppShell.Aside withBorder />
@@ -0,0 +1,68 @@
import { useLayoutEffect, useRef } from "react";
import { useRouterState } from "@tanstack/react-router";
const TRAVEL_MS = 333;
export const useNavIndicator = (isMobile: boolean) => {
const containerRef = useRef<HTMLDivElement>(null);
const indicatorRef = useRef<HTMLSpanElement>(null);
const shapeRef = useRef<HTMLSpanElement>(null);
const pathname = useRouterState({ select: (s) => s.location.pathname });
useLayoutEffect(() => {
const container = containerRef.current;
const indicator = indicatorRef.current;
const shape = shapeRef.current;
if (!container || !indicator || !shape) return;
let last: number | null = null;
const place = () => {
const active = container.querySelector<HTMLElement>("[data-active]");
if (!active) {
delete indicator.dataset.placed;
last = null;
return;
}
const x = active.offsetLeft;
const y = active.offsetTop;
const travelled = isMobile ? x : y;
const first = indicator.dataset.placed !== "1";
if (first) indicator.style.transition = "none";
indicator.style.setProperty("--nav-x", `${x}px`);
indicator.style.setProperty("--nav-y", `${y}px`);
indicator.style.setProperty("--nav-w", `${active.offsetWidth}px`);
indicator.style.setProperty("--nav-h", `${active.offsetHeight}px`);
indicator.dataset.placed = "1";
if (first) {
indicator.getBoundingClientRect();
indicator.style.transition = "";
} else if (
last !== null &&
Math.abs(travelled - last) > 1 &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
const axis = isMobile ? "scaleX" : "scaleY";
shape.animate(
[
{ transform: `${axis}(1)`, offset: 0 },
{ transform: `${axis}(0.88)`, offset: 0.4 },
{ transform: `${axis}(1)`, offset: 1 },
],
{ duration: TRAVEL_MS, easing: "ease-in-out" }
);
}
last = travelled;
};
place();
const observer = new ResizeObserver(place);
observer.observe(container);
return () => observer.disconnect();
}, [pathname, isMobile]);
return { containerRef, indicatorRef, shapeRef };
};
@@ -32,30 +32,19 @@ export const NavLink = ({
<Box
component={Link}
to={href}
className={styles.navLinkBox}
className={`flxn-nav-link ${styles.navLinkBox}`}
p={{ base: 0, sm: 8 }}
aria-label={label}
aria-current={isActive ? "page" : undefined}
data-active={isActive || undefined}
>
<Flex
direction={{ base: "column", md: "row" }}
align="center"
gap={{ base: 0, md: "xs" }}
>
<Icon
weight={isActive ? "fill" : "regular"}
size={28}
style={{
color: isActive ? "var(--mantine-primary-color-filled)" : undefined,
}}
/>
<Text
visibleFrom="md"
ta="center"
size="md"
fw={isActive ? 800 : 500}
c={isActive ? "var(--mantine-primary-color-filled)" : undefined}
>
<Icon weight={isActive ? "fill" : "regular"} size={28} />
<Text visibleFrom="md" ta="center" size="md" fw={isActive ? 800 : 500}>
{label}
</Text>
</Flex>
+14 -4
View File
@@ -5,11 +5,13 @@ import { useAuth } from "@/contexts/auth-context";
import { useLinks } from "../hooks/use-links";
import { memo } from "react";
import { useIsPWA } from "@/hooks/use-is-pwa";
import { useNavIndicator } from "./nav-indicator/use-nav-indicator";
const Navbar = () => {
const { user, roles } = useAuth()
const isMobile = useIsMobile();
const isPWA = useIsPWA();
const { containerRef, indicatorRef, shapeRef } = useNavIndicator(isMobile);
const links = useLinks(user?.id, roles);
@@ -17,9 +19,16 @@ const Navbar = () => {
? 'max(env(safe-area-inset-bottom, 0px), 0.5rem)'
: 'env(safe-area-inset-bottom, 0px)';
const indicator = (
<span ref={indicatorRef} className="flxn-nav-indicator" aria-hidden="true">
<span ref={shapeRef} className="flxn-nav-indicator-shape" />
</span>
);
if (isMobile) return (
<Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom={bottomOffset} style={{ zIndex: 10 }}>
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
<Paper className="flxn-nav" component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1rem)' pos='fixed' m='0.5rem' bottom={bottomOffset} style={{ zIndex: 10 }}>
<Group ref={containerRef} pos='relative' gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
{indicator}
{links.map((link) => (
<NavLink key={link.href} {...link} />
))}
@@ -27,9 +36,10 @@ const Navbar = () => {
</Paper>
)
return <AppShell.Navbar p="xs" role='navigation'>
return <AppShell.Navbar className="flxn-nav" p="xs" role='navigation'>
<AppShell.Section grow component={ScrollArea}>
<Stack gap='xs' mx='auto' w='fit-content' justify='end' mt='md'>
<Stack ref={containerRef} pos='relative' gap='xs' mx='auto' w='fit-content' justify='end' mt='md'>
{indicator}
{links.map((link) => (
<NavLink key={link.href} {...link} />
))}
-52
View File
@@ -1,52 +0,0 @@
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";
}
+4
View File
@@ -4,6 +4,10 @@
explicit [data-mantine-color-scheme] blocks. */
:root {
/* bottom-nav indicator travel: spring (stiffness 320, damping 30) */
--flxn-nav-travel: linear(0, 0.0102, 0.0376, 0.0778, 0.1272, 0.1829, 0.2422, 0.3033, 0.3646, 0.4247, 0.4829, 0.5383, 0.5906, 0.6393, 0.6843, 0.7256, 0.7631, 0.7969, 0.8273, 0.8543, 0.8783, 0.8993, 0.9176, 0.9335, 0.9472, 0.9589, 0.9688, 0.9772, 0.9841, 0.9898, 0.9945, 0.9982, 1.0011, 1.0034, 1.0051, 1.0063, 1.0072, 1.0077, 1.0079, 1.0079, 1.0078);
--flxn-nav-travel-ms: 333ms;
/* accolades */
--flxn-gold: gold;
--flxn-silver: var(--mantine-color-gray-5);
+58
View File
@@ -67,3 +67,61 @@
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
}
/* Bottom-nav active indicator: one element glides between tabs. The surface is
neutral on purpose — the accent belongs to the glyph, not a tint behind it. */
.flxn-nav-indicator {
position: absolute;
top: 0;
left: 0;
width: var(--nav-w, 0);
height: var(--nav-h, 0);
translate: var(--nav-x, 0) var(--nav-y, 0);
pointer-events: none;
opacity: 0;
z-index: 0;
transition: translate var(--flxn-nav-travel-ms) var(--flxn-nav-travel),
width var(--flxn-nav-travel-ms) var(--flxn-nav-travel),
height var(--flxn-nav-travel-ms) var(--flxn-nav-travel),
opacity 140ms ease-out;
}
.flxn-nav-indicator[data-placed="1"] {
opacity: 1;
}
.flxn-nav-indicator-shape {
display: block;
width: calc(100% + var(--flxn-nav-pad-x, 12px));
height: calc(100% + var(--flxn-nav-pad-y, 6px));
margin: calc(var(--flxn-nav-pad-y, 6px) / -2) calc(var(--flxn-nav-pad-x, 12px) / -2);
background: var(--flxn-nav-surface, var(--mantine-color-default));
border: 1px solid var(--flxn-nav-border, var(--mantine-color-default-border));
border-radius: var(--flxn-nav-radius, var(--mantine-radius-md));
box-shadow: var(--mantine-shadow-xs);
}
.flxn-nav-link {
/* lifted above the indicator, or an opaque fill covers the glyph */
position: relative;
z-index: 1;
}
.flxn-nav-link svg,
.flxn-nav-link p {
transition: color 140ms ease-out;
}
.flxn-nav-link[data-active] {
color: var(--flxn-nav-accent, var(--mantine-primary-color-filled));
}
@media (prefers-reduced-motion: no-preference) {
.flxn-nav-link[data-active] svg {
animation: flxn-nav-pop 320ms cubic-bezier(0.32, 0.72, 0, 1);
}
}
@keyframes flxn-nav-pop {
0% { scale: 1; }
38% { scale: 1.16; }
100% { scale: 1; }
}
@media (prefers-reduced-motion: reduce) {
.flxn-nav-indicator {
transition: opacity 120ms ease-out;
}
}
-8
View File
@@ -167,14 +167,6 @@
opacity: 0.07;
}
[data-theme="sketch"]::view-transition-new(theme-overlay) {
mix-blend-mode: multiply;
}
[data-theme="sketch"][data-mantine-color-scheme="dark"]::view-transition-new(theme-overlay) {
mix-blend-mode: screen;
}
/* ---------------------------------------------------------------- */
/* 4. Surfaces — every card its own doodle */
/* ---------------------------------------------------------------- */