Development #26
@@ -4,7 +4,6 @@ import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query
|
|||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
||||||
import RoutePending from "../components/route-pending";
|
import RoutePending from "../components/route-pending";
|
||||||
import { headerChange, navDirection } from "@/features/core/utils/nav-direction";
|
|
||||||
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
||||||
|
|
||||||
export function getRouter() {
|
export function getRouter() {
|
||||||
@@ -34,12 +33,6 @@ export function getRouter() {
|
|||||||
defaultPreloadStaleTime: 60_000,
|
defaultPreloadStaleTime: 60_000,
|
||||||
defaultPendingMs: 100,
|
defaultPendingMs: 100,
|
||||||
defaultPendingMinMs: 0,
|
defaultPendingMinMs: 0,
|
||||||
defaultViewTransition: {
|
|
||||||
types: ({ fromLocation, toLocation }) => {
|
|
||||||
const direction = navDirection(fromLocation, toLocation);
|
|
||||||
return direction === "none" ? false : [direction];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultPendingComponent: RoutePending,
|
defaultPendingComponent: RoutePending,
|
||||||
defaultErrorComponent: DefaultCatchBoundary,
|
defaultErrorComponent: DefaultCatchBoundary,
|
||||||
});
|
});
|
||||||
@@ -49,13 +42,6 @@ export function getRouter() {
|
|||||||
queryClient
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-108
@@ -50,120 +50,13 @@ textarea,
|
|||||||
transform: scale(0.90) translateY(-4px);
|
transform: scale(0.90) translateY(-4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* keep the page tappable mid-transition (rapid tab switching) */
|
/* themes paint their full-page texture through this element */
|
||||||
::view-transition {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* theme overlays must be this element, not body::before, or they blink off
|
|
||||||
during transitions */
|
|
||||||
.flxn-theme-overlay {
|
.flxn-theme-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
pointer-events: none;
|
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) {
|
@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,
|
.app,
|
||||||
[data-drawer-level] {
|
[data-drawer-level] {
|
||||||
transition: none;
|
transition: none;
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig)
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||||
viewTransitionName: 'app-header',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ withBackButton && <BackButton /> }
|
{ withBackButton && <BackButton /> }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AppShell, Box } from '@mantine/core';
|
import { AppShell } from '@mantine/core';
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import Header from './header';
|
import Header from './header';
|
||||||
import Navbar from './navbar';
|
import Navbar from './navbar';
|
||||||
@@ -15,7 +15,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
<AppShell
|
<AppShell
|
||||||
id='app-shell'
|
id='app-shell'
|
||||||
layout='alt'
|
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}
|
transitionDuration={0}
|
||||||
header={{
|
header={{
|
||||||
// Old installs froze black-translucent at add-to-home-screen time, so
|
// 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 }}
|
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
|
||||||
maw='100dvw'
|
maw='100dvw'
|
||||||
style={{ overflow: 'hidden' }}
|
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>
|
<Pullable>
|
||||||
<Page noPadding={!withPadding} fullWidth={fullWidth}>
|
<Page noPadding={!withPadding} fullWidth={fullWidth}>
|
||||||
{children}
|
{children}
|
||||||
</Page>
|
</Page>
|
||||||
</Pullable>
|
</Pullable>
|
||||||
</Box>
|
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<AppShell.Aside withBorder />
|
<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
|
<Box
|
||||||
component={Link}
|
component={Link}
|
||||||
to={href}
|
to={href}
|
||||||
className={styles.navLinkBox}
|
className={`flxn-nav-link ${styles.navLinkBox}`}
|
||||||
p={{ base: 0, sm: 8 }}
|
p={{ base: 0, sm: 8 }}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-active={isActive || undefined}
|
||||||
>
|
>
|
||||||
<Flex
|
<Flex
|
||||||
direction={{ base: "column", md: "row" }}
|
direction={{ base: "column", md: "row" }}
|
||||||
align="center"
|
align="center"
|
||||||
gap={{ base: 0, md: "xs" }}
|
gap={{ base: 0, md: "xs" }}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon weight={isActive ? "fill" : "regular"} size={28} />
|
||||||
weight={isActive ? "fill" : "regular"}
|
<Text visibleFrom="md" ta="center" size="md" fw={isActive ? 800 : 500}>
|
||||||
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}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { useAuth } from "@/contexts/auth-context";
|
|||||||
import { useLinks } from "../hooks/use-links";
|
import { useLinks } from "../hooks/use-links";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { useIsPWA } from "@/hooks/use-is-pwa";
|
import { useIsPWA } from "@/hooks/use-is-pwa";
|
||||||
|
import { useNavIndicator } from "./nav-indicator/use-nav-indicator";
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const { user, roles } = useAuth()
|
const { user, roles } = useAuth()
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const isPWA = useIsPWA();
|
const isPWA = useIsPWA();
|
||||||
|
const { containerRef, indicatorRef, shapeRef } = useNavIndicator(isMobile);
|
||||||
|
|
||||||
const links = useLinks(user?.id, roles);
|
const links = useLinks(user?.id, roles);
|
||||||
|
|
||||||
@@ -17,9 +19,16 @@ const Navbar = () => {
|
|||||||
? 'max(env(safe-area-inset-bottom, 0px), 0.5rem)'
|
? 'max(env(safe-area-inset-bottom, 0px), 0.5rem)'
|
||||||
: 'env(safe-area-inset-bottom, 0px)';
|
: '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 (
|
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 }}>
|
<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 gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
<Group ref={containerRef} pos='relative' gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
||||||
|
{indicator}
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<NavLink key={link.href} {...link} />
|
<NavLink key={link.href} {...link} />
|
||||||
))}
|
))}
|
||||||
@@ -27,9 +36,10 @@ const Navbar = () => {
|
|||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
|
|
||||||
return <AppShell.Navbar p="xs" role='navigation'>
|
return <AppShell.Navbar className="flxn-nav" p="xs" role='navigation'>
|
||||||
<AppShell.Section grow component={ScrollArea}>
|
<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) => (
|
{links.map((link) => (
|
||||||
<NavLink key={link.href} {...link} />
|
<NavLink key={link.href} {...link} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,6 +4,10 @@
|
|||||||
explicit [data-mantine-color-scheme] blocks. */
|
explicit [data-mantine-color-scheme] blocks. */
|
||||||
|
|
||||||
:root {
|
: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 */
|
/* accolades */
|
||||||
--flxn-gold: gold;
|
--flxn-gold: gold;
|
||||||
--flxn-silver: var(--mantine-color-gray-5);
|
--flxn-silver: var(--mantine-color-gray-5);
|
||||||
|
|||||||
@@ -67,3 +67,61 @@
|
|||||||
border: 1px solid var(--mantine-color-default-border);
|
border: 1px solid var(--mantine-color-default-border);
|
||||||
border-radius: var(--mantine-radius-md);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -167,14 +167,6 @@
|
|||||||
opacity: 0.07;
|
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 */
|
/* 4. Surfaces — every card its own doodle */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|||||||
Reference in New Issue
Block a user