6 Commits
Author SHA1 Message Date
kyle 0493c08dae Merge pull request 'Development' (#26) from development into main
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 13s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 37s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m29s
Reviewed-on: #26
2026-09-14 03:28:42 -05:00
kyle 9febb8bb60 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
2026-09-14 00:53:24 -07:00
kyle a754fc0282 match cards h2h margins
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 11s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 37s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m11s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m30s
2026-09-11 11:53:27 -07:00
kyle 6f17a5d4a8 slide animation
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 44s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m10s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m31s
2026-09-11 10:28:45 -07:00
kyle 9ed3f7169e slide animation
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 13s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 31s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m25s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m29s
2026-09-11 08:32:42 -07:00
kyle c4a9d47180 slide animation
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 14s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 33s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m27s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m28s
2026-09-10 22:21:16 -07:00
37 changed files with 274 additions and 132 deletions
+9 -9
View File
@@ -31,8 +31,8 @@
"facehash": "^0.0.7", "facehash": "^0.0.7",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
"pocketbase": "^0.26.2", "pocketbase": "^0.26.2",
"react": "^19.2.7", "react": "^19.3.0",
"react-dom": "^19.2.7", "react-dom": "^19.3.0",
"react-imask": "^7.6.1", "react-imask": "^7.6.1",
"redaxios": "^0.5.1", "redaxios": "^0.5.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@@ -49,8 +49,8 @@
"@lingui/cli": "^6.5.0", "@lingui/cli": "^6.5.0",
"@lingui/vite-plugin": "^6.5.0", "@lingui/vite-plugin": "^6.5.0",
"@types/node": "^22.5.4", "@types/node": "^22.5.4",
"@types/react": "^19.2.17", "@types/react": "^19.3.0",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.3.0",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20", "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/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=="], "@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=="], "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=="], "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=="], "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=="], "scmp": ["scmp@2.1.0", "", {}, "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="],
+4 -4
View File
@@ -43,8 +43,8 @@
"facehash": "^0.0.7", "facehash": "^0.0.7",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
"pocketbase": "^0.26.2", "pocketbase": "^0.26.2",
"react": "^19.2.7", "react": "^19.3.0",
"react-dom": "^19.2.7", "react-dom": "^19.3.0",
"react-imask": "^7.6.1", "react-imask": "^7.6.1",
"redaxios": "^0.5.1", "redaxios": "^0.5.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@@ -61,8 +61,8 @@
"@lingui/cli": "^6.5.0", "@lingui/cli": "^6.5.0",
"@lingui/vite-plugin": "^6.5.0", "@lingui/vite-plugin": "^6.5.0",
"@types/node": "^22.5.4", "@types/node": "^22.5.4",
"@types/react": "^19.2.17", "@types/react": "^19.3.0",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.3.0",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
-1
View File
@@ -13,7 +13,6 @@ const { count, size, warnings } = await generateSW({
'icon-192x192.png', 'icon-192x192.png',
'icon-512x512.png', 'icon-512x512.png',
'site.webmanifest', 'site.webmanifest',
'styles.css',
'push-handlers.js', 'push-handlers.js',
], ],
// Hand-written Web Push handlers (public/push-handlers.js) are pulled into // Hand-written Web Push handlers (public/push-handlers.js) are pulled into
+4
View File
@@ -3,6 +3,7 @@ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query"; 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 { defaultHeaderConfig } from "@/features/core/hooks/use-router-config"; import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
export function getRouter() { export function getRouter() {
@@ -30,6 +31,9 @@ export function getRouter() {
}, },
defaultPreload: "intent", defaultPreload: "intent",
defaultPreloadStaleTime: 60_000, defaultPreloadStaleTime: 60_000,
defaultPendingMs: 100,
defaultPendingMinMs: 0,
defaultPendingComponent: RoutePending,
defaultErrorComponent: DefaultCatchBoundary, defaultErrorComponent: DefaultCatchBoundary,
}); });
+16 -2
View File
@@ -31,6 +31,7 @@ import { getRootMeta } from "@/lib/i18n/meta";
import FullScreenLoader from "@/components/full-screen-loader"; import FullScreenLoader from "@/components/full-screen-loader";
import { CHROME_COLORS } from "@/lib/mantine/theme-colors"; import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
import mantineCssUrl from '@mantine/core/styles.css?url' import mantineCssUrl from '@mantine/core/styles.css?url'
import appStylesUrl from '../styles.css?url'
import mantineDatesCssUrl from '@mantine/dates/styles.css?url' import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url' import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url'
import mantineTiptapCssUrl from '@mantine/tiptap/styles.css?url' import mantineTiptapCssUrl from '@mantine/tiptap/styles.css?url'
@@ -101,6 +102,7 @@ export const Route = createRootRouteWithContext<{
}, },
{ rel: "manifest", href: "/site.webmanifest" }, { rel: "manifest", href: "/site.webmanifest" },
{ rel: "icon", href: "/favicon.ico" }, { rel: "icon", href: "/favicon.ico" },
{ rel: 'stylesheet', href: appStylesUrl },
{ rel: 'stylesheet', href: mantineCssUrl }, { rel: 'stylesheet', href: mantineCssUrl },
{ rel: 'stylesheet', href: mantineCarouselCssUrl }, { rel: 'stylesheet', href: mantineCarouselCssUrl },
{ rel: 'stylesheet', href: mantineDatesCssUrl }, { rel: 'stylesheet', href: mantineDatesCssUrl },
@@ -183,7 +185,19 @@ function RootComponent() {
ensureSuperTokensFrontend(); ensureSuperTokensFrontend();
initTelemetry(router); initTelemetry(router);
if (import.meta.env.PROD && 'serviceWorker' in navigator) { 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]); }, [router]);
@@ -231,7 +245,6 @@ function RootDocument({ children }: { children: React.ReactNode }) {
'try{var d=document.documentElement,t=localStorage.getItem("flxn-theme");if(t&&t!=="default"){d.setAttribute("data-theme",t);}var s=localStorage.getItem("flxn-theme-scheme");if(s){d.setAttribute("data-mantine-color-scheme",s);}var f=JSON.parse(localStorage.getItem("flxn-theme-fonts")||"[]");for(var i=0;i<f.length;i++){var l=document.createElement("link");l.rel="stylesheet";l.href=f[i];l.setAttribute("data-flxn-theme-font",t||"default");document.head.appendChild(l);}}catch(e){}', 'try{var d=document.documentElement,t=localStorage.getItem("flxn-theme");if(t&&t!=="default"){d.setAttribute("data-theme",t);}var s=localStorage.getItem("flxn-theme-scheme");if(s){d.setAttribute("data-mantine-color-scheme",s);}var f=JSON.parse(localStorage.getItem("flxn-theme-fonts")||"[]");for(var i=0;i<f.length;i++){var l=document.createElement("link");l.rel="stylesheet";l.href=f[i];l.setAttribute("data-flxn-theme-font",t||"default");document.head.appendChild(l);}}catch(e){}',
}} }}
/> />
<link rel="stylesheet" href="/styles.css" />
</head> </head>
<body <body
style={{ style={{
@@ -243,6 +256,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
}} }}
> >
<div className="app">{children}</div> <div className="app">{children}</div>
<div className="flxn-theme-overlay" aria-hidden="true" />
<Scripts /> <Scripts />
</body> </body>
</html> </html>
+5 -7
View File
@@ -1,7 +1,8 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/queries"; import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/queries";
import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament"; 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 StartedTournament from "@/features/tournaments/components/started-tournament";
import { Suspense } from "react"; import { Suspense } from "react";
import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton"; import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton";
@@ -10,16 +11,13 @@ export const Route = createFileRoute("/_authed/")({
component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}> component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}>
<Home /> <Home />
</Suspense>, </Suspense>,
beforeLoad: async ({ context }) => { beforeLoad: ({ context }) => {
const queryClient = context.queryClient; prefetchServerQuery(context.queryClient, tournamentQueries.current());
const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current())
return { tournament }
}, },
loader: ({ context }) => ({ loader: ({ context }) => ({
withPadding: false, withPadding: false,
header: { header: {
title: context.tournament.name || "FLXN" title: getServerQueryData(context.queryClient, tournamentQueries.current())?.name || "FLXN"
} }
}), }),
pendingComponent: () => <UpcomingTournamentSkeleton /> pendingComponent: () => <UpcomingTournamentSkeleton />
+5 -19
View File
@@ -14,26 +14,12 @@ const searchSchema = z.object({
export const Route = createFileRoute("/_authed/profile/$playerId")({ export const Route = createFileRoute("/_authed/profile/$playerId")({
validateSearch: searchSchema, validateSearch: searchSchema,
beforeLoad: async ({ params, context }) => { beforeLoad: ({ params, context }) => {
const { queryClient } = context; const { queryClient } = context;
await Promise.all([ prefetchServerQuery(queryClient, playerQueries.details(params.playerId));
prefetchServerQuery( prefetchServerQuery(queryClient, playerQueries.matches(params.playerId));
queryClient, prefetchServerQuery(queryClient, playerQueries.stats(params.playerId));
playerQueries.details(params.playerId) prefetchServerQuery(queryClient, badgeQueries.playerBadges(params.playerId));
),
prefetchServerQuery(
queryClient,
playerQueries.matches(params.playerId)
),
prefetchServerQuery(
queryClient,
playerQueries.stats(params.playerId)
),
prefetchServerQuery(
queryClient,
badgeQueries.playerBadges(params.playerId)
),
]);
}, },
loader: ({ params, context }) => ({ loader: ({ params, context }) => ({
header: { header: {
+2 -2
View File
@@ -12,9 +12,9 @@ const searchSchema = z.object({
export const Route = createFileRoute("/_authed/teams/$teamId")({ export const Route = createFileRoute("/_authed/teams/$teamId")({
validateSearch: searchSchema, validateSearch: searchSchema,
beforeLoad: async ({ params, context }) => { beforeLoad: ({ params, context }) => {
const { queryClient } = context; const { queryClient } = context;
await prefetchServerQuery(queryClient, teamQueries.details(params.teamId)); prefetchServerQuery(queryClient, teamQueries.details(params.teamId));
}, },
loader: ({ params }) => ({ loader: ({ params }) => ({
header: { header: {
+6
View File
@@ -50,6 +50,12 @@ textarea,
transform: scale(0.90) translateY(-4px); transform: scale(0.90) translateY(-4px);
} }
/* themes paint their full-page texture through this element */
.flxn-theme-overlay {
position: fixed;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.app, .app,
[data-drawer-level] { [data-drawer-level] {
+17
View File
@@ -0,0 +1,17 @@
import { Skeleton, Stack } from "@mantine/core";
const RoutePending = () => (
<Stack gap="md" p="md" w="100%">
{Array.from({ length: 5 }).map((_, index) => (
<Skeleton
key={`route-pending-${index}`}
height={96}
w="100%"
radius="md"
style={{ opacity: Math.max(1 - index * 0.15, 0.3) }}
/>
))}
</Stack>
);
export default RoutePending;
+8 -2
View File
@@ -6,7 +6,7 @@ import {
ReactNode, ReactNode,
useRef, useRef,
useCallback, useCallback,
useMemo, startTransition,
} from "react"; } from "react";
import { useRouter } from "@tanstack/react-router"; import { useRouter } from "@tanstack/react-router";
@@ -48,6 +48,9 @@ function SwipeableTabs({
}, [search?.tab, tabs, defaultTab]); }, [search?.tab, tabs, defaultTab]);
const [activeTab, setActiveTab] = useState(getActiveTabFromUrl); const [activeTab, setActiveTab] = useState(getActiveTabFromUrl);
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<number>>(
() => new Set([getActiveTabFromUrl()])
);
const [stuck, setStuck] = useState(false); const [stuck, setStuck] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null); const sentinelRef = useRef<HTMLDivElement>(null);
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null); const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
@@ -60,6 +63,9 @@ function SwipeableTabs({
if (index === activeTab || index < 0 || index >= tabs.length) return; if (index === activeTab || index < 0 || index >= tabs.length) return;
setActiveTab(index); setActiveTab(index);
startTransition(() => {
setVisitedTabs((prev) => (prev.has(index) ? prev : new Set(prev).add(index)));
});
embla?.scrollTo(index); embla?.scrollTo(index);
onTabChange?.(index, tabs[index]); onTabChange?.(index, tabs[index]);
@@ -225,7 +231,7 @@ function SwipeableTabs({
{tabs.map((tab, index) => ( {tabs.map((tab, index) => (
<Carousel.Slide key={`${tab.label}-content-${index}`}> <Carousel.Slide key={`${tab.label}-content-${index}`}>
<Box ref={setSlideRef(index)} style={{ height: "auto", width: "100%", maxWidth: "100vw", overflow: "hidden" }}> <Box ref={setSlideRef(index)} style={{ height: "auto", width: "100%", maxWidth: "100vw", overflow: "hidden" }}>
{tab.content} {(visitedTabs.has(index) || index === activeTab) ? tab.content : null}
</Box> </Box>
</Carousel.Slide> </Carousel.Slide>
))} ))}
@@ -49,6 +49,7 @@ export const BadgeIcon = ({ badge, filled, size = 48 }: BadgeIconProps & { size?
width={size} width={size}
height={size} height={size}
loading="lazy" loading="lazy"
decoding="async"
onError={() => setImageError(true)} onError={() => setImageError(true)}
style={{ style={{
objectFit: 'contain', objectFit: 'contain',
+5 -8
View File
@@ -1,23 +1,22 @@
import { AppShell, Box } from '@mantine/core'; import { AppShell } from '@mantine/core';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { useLocation } from '@tanstack/react-router';
import Header from './header'; import Header from './header';
import Navbar from './navbar'; import Navbar from './navbar';
import Pullable from './pullable'; import Pullable from './pullable';
import useVisualViewportSize from '../hooks/use-visual-viewport-size'; import useVisualViewportSize from '../hooks/use-visual-viewport-size';
import useRouterConfig from '../hooks/use-router-config'; import useRouterConfig from '../hooks/use-router-config';
import Page from '@/components/page'; import Page from '@/components/page';
import './route-transition.css';
const Layout: React.FC<PropsWithChildren> = ({ children }) => { const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const { header, withPadding, fullWidth } = useRouterConfig(); const { header, withPadding, fullWidth } = useRouterConfig();
const viewport = useVisualViewportSize(); const viewport = useVisualViewportSize();
const location = useLocation();
return ( return (
<AppShell <AppShell
id='app-shell' id='app-shell'
layout='alt' layout='alt'
// 0 = header collapse is instant; Mantine's default animates main's padding
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
// the webview can extend under the status bar; inset is 0 elsewhere. // the webview can extend under the status bar; inset is 0 elsewhere.
@@ -48,16 +47,14 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
pos='relative' pos='relative'
h='100%' h='100%'
mah='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 }} px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
maw='100dvw' maw='100dvw'
style={{ overflow: 'hidden' }} style={{ overflow: 'hidden' }}
> >
<Pullable> <Pullable>
<Page noPadding={!withPadding} fullWidth={fullWidth}> <Page noPadding={!withPadding} fullWidth={fullWidth}>
<Box key={location.pathname} className="flxn-route-enter"> {children}
{children}
</Box>
</Page> </Page>
</Pullable> </Pullable>
</AppShell.Main> </AppShell.Main>
@@ -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>
@@ -11,11 +11,6 @@
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.navLinkBox svg,
.navLinkBox p {
transition: color 150ms ease-out;
}
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.navLinkBox { .navLinkBox {
transition: transform 150ms cubic-bezier(0.32, 0.72, 0, 1); transition: transform 150ms cubic-bezier(0.32, 0.72, 0, 1);
+14 -4
View File
@@ -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,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;
}
}
@@ -1,4 +1,4 @@
import { Stack, Text } from "@mantine/core"; import { MantineSpacing, Stack, Text } from "@mantine/core";
import { useMemo } from "react"; import { useMemo } from "react";
import { Match } from "../types"; import { Match } from "../types";
import MatchCard from "./match-card"; import MatchCard from "./match-card";
@@ -7,9 +7,10 @@ import { Trans } from "@lingui/react/macro";
interface MatchListProps { interface MatchListProps {
matches: Match[]; matches: Match[];
hideH2H?: boolean; hideH2H?: boolean;
p?: MantineSpacing;
} }
const MatchList = ({ matches, hideH2H = false }: MatchListProps) => { const MatchList = ({ matches, hideH2H = false, p = "md" }: MatchListProps) => {
const filteredMatches = useMemo( const filteredMatches = useMemo(
() => () =>
[...(matches ?? [])] [...(matches ?? [])]
@@ -27,7 +28,7 @@ const MatchList = ({ matches, hideH2H = false }: MatchListProps) => {
const isRegional = filteredMatches[0]?.tournament?.regional; const isRegional = filteredMatches[0]?.tournament?.regional;
return ( return (
<Stack p="md" gap="sm"> <Stack p={p} gap="sm">
{isRegional && ( {isRegional && (
<Text size="xs" c="dimmed" ta="center" px="md"> <Text size="xs" c="dimmed" ta="center" px="md">
<Trans>Matches for regionals are unordered</Trans> <Trans>Matches for regionals are unordered</Trans>
@@ -200,10 +200,10 @@ const TeamHeadToHeadContent = ({ team1, team2, isOpen = true }: TeamHeadToHeadSh
</Stack> </Stack>
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm" fw={600} px="md"> <Text size="sm" fw={600}>
<Trans>Match History (<Plural value={totalMatches} one="# match" other="# matches" />)</Trans> <Trans>Match History (<Plural value={totalMatches} one="# match" other="# matches" />)</Trans>
</Text> </Text>
<MatchList matches={matches} hideH2H /> <MatchList matches={matches} hideH2H p={0} />
</Stack> </Stack>
</Stack> </Stack>
); );
@@ -260,10 +260,10 @@ const PlayerHeadToHeadContent = ({
</Stack> </Stack>
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm" fw={600} px="md"> <Text size="sm" fw={600}>
<Trans>Match History ({totalMatches})</Trans> <Trans>Match History ({totalMatches})</Trans>
</Text> </Text>
<MatchList matches={matches} hideH2H /> <MatchList matches={matches} hideH2H p={0} />
</Stack> </Stack>
</Stack> </Stack>
); );
+10
View File
@@ -1,6 +1,16 @@
import { QueryClient, QueryKey } from "@tanstack/react-query"; import { QueryClient, QueryKey } from "@tanstack/react-query";
import { ServerResult } from "../types"; import { ServerResult } from "../types";
export function getServerQueryData<TData>(
queryClient: QueryClient,
query: {
queryKey: QueryKey;
queryFn: () => Promise<ServerResult<TData>>;
}
): TData | undefined {
return queryClient.getQueryData<TData>(query.queryKey);
}
export async function ensureServerQueryData<TData>( export async function ensureServerQueryData<TData>(
queryClient: QueryClient, queryClient: QueryClient,
query: { query: {
+2 -2
View File
@@ -1621,7 +1621,7 @@ msgstr "Match gestartet!"
msgid "Matches" msgid "Matches"
msgstr "Matches" msgstr "Matches"
#: src/features/matches/components/match-list.tsx:33 #: src/features/matches/components/match-list.tsx:34
msgid "Matches for regionals are unordered" msgid "Matches for regionals are unordered"
msgstr "Matches für Regionals sind ungeordnet" msgstr "Matches für Regionals sind ungeordnet"
@@ -2091,7 +2091,7 @@ msgstr "Vorheriger Titel"
msgid "Profile" msgid "Profile"
msgstr "Profil" msgstr "Profil"
#: src/features/badges/components/badge-showcase.tsx:301 #: src/features/badges/components/badge-showcase.tsx:302
msgid "Progress " msgid "Progress "
msgstr "Fortschritt " msgstr "Fortschritt "
+2 -2
View File
@@ -1621,7 +1621,7 @@ msgstr "Match started!"
msgid "Matches" msgid "Matches"
msgstr "Matches" msgstr "Matches"
#: src/features/matches/components/match-list.tsx:33 #: src/features/matches/components/match-list.tsx:34
msgid "Matches for regionals are unordered" msgid "Matches for regionals are unordered"
msgstr "Matches for regionals are unordered" msgstr "Matches for regionals are unordered"
@@ -2091,7 +2091,7 @@ msgstr "Previous track"
msgid "Profile" msgid "Profile"
msgstr "Profile" msgstr "Profile"
#: src/features/badges/components/badge-showcase.tsx:301 #: src/features/badges/components/badge-showcase.tsx:302
msgid "Progress " msgid "Progress "
msgstr "Progress " msgstr "Progress "
+2 -2
View File
@@ -1621,7 +1621,7 @@ msgstr "¡Empezó el partido!"
msgid "Matches" msgid "Matches"
msgstr "Partidos" msgstr "Partidos"
#: src/features/matches/components/match-list.tsx:33 #: src/features/matches/components/match-list.tsx:34
msgid "Matches for regionals are unordered" msgid "Matches for regionals are unordered"
msgstr "Los partidos de regionales no tienen un orden específico" msgstr "Los partidos de regionales no tienen un orden específico"
@@ -2091,7 +2091,7 @@ msgstr "Pista anterior"
msgid "Profile" msgid "Profile"
msgstr "Perfil" msgstr "Perfil"
#: src/features/badges/components/badge-showcase.tsx:301 #: src/features/badges/components/badge-showcase.tsx:302
msgid "Progress " msgid "Progress "
msgstr "Progreso " msgstr "Progreso "
+2 -2
View File
@@ -1621,7 +1621,7 @@ msgstr "試合開始!"
msgid "Matches" msgid "Matches"
msgstr "試合" msgstr "試合"
#: src/features/matches/components/match-list.tsx:33 #: src/features/matches/components/match-list.tsx:34
msgid "Matches for regionals are unordered" msgid "Matches for regionals are unordered"
msgstr "地区予選の試合は順不同です" msgstr "地区予選の試合は順不同です"
@@ -2091,7 +2091,7 @@ msgstr "前のトラック"
msgid "Profile" msgid "Profile"
msgstr "プロフィール" msgstr "プロフィール"
#: src/features/badges/components/badge-showcase.tsx:301 #: src/features/badges/components/badge-showcase.tsx:302
msgid "Progress " msgid "Progress "
msgstr "進行状況 " msgstr "進行状況 "
+3 -4
View File
@@ -178,15 +178,14 @@
/* 3. Fixed sky backdrop with static bubbles (below all content) */ /* 3. Fixed sky backdrop with static bubbles (below all content) */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
[data-theme="aero"] body::before { [data-theme="aero"] .flxn-theme-overlay {
content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
} }
[data-theme="aero"][data-mantine-color-scheme="light"] body::before { [data-theme="aero"][data-mantine-color-scheme="light"] .flxn-theme-overlay {
background-color: #e9f6fe; background-color: #e9f6fe;
background-image: background-image:
radial-gradient(circle 0.6rem at 13.5% 20%, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0) 70%), radial-gradient(circle 0.6rem at 13.5% 20%, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0) 70%),
@@ -198,7 +197,7 @@
linear-gradient(180deg, #a5daf6 0%, #c9e9fa 32%, #e9f6fe 60%, #ffffff 100%); linear-gradient(180deg, #a5daf6 0%, #c9e9fa 32%, #e9f6fe 60%, #ffffff 100%);
} }
[data-theme="aero"][data-mantine-color-scheme="dark"] body::before { [data-theme="aero"][data-mantine-color-scheme="dark"] .flxn-theme-overlay {
background-color: #071e2d; background-color: #071e2d;
background-image: background-image:
radial-gradient(circle 4rem at 16% 24%, rgba(120, 205, 245, 0) 60%, rgba(120, 205, 245, 0.2) 67%, rgba(120, 205, 245, 0) 74%), radial-gradient(circle 4rem at 16% 24%, rgba(120, 205, 245, 0) 60%, rgba(120, 205, 245, 0.2) 67%, rgba(120, 205, 245, 0) 74%),
+4
View File
@@ -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);
+3 -4
View File
@@ -203,8 +203,7 @@
/* ---------- 4. Backdrop — studio key light + clay grain ---------- */ /* ---------- 4. Backdrop — studio key light + clay grain ---------- */
[data-theme="clay"] body::before { [data-theme="clay"] .flxn-theme-overlay {
content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: -1; /* behind content — never tints text or surfaces */ z-index: -1; /* behind content — never tints text or surfaces */
@@ -212,7 +211,7 @@
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E");
} }
[data-theme="clay"][data-mantine-color-scheme="light"] body::before { [data-theme="clay"][data-mantine-color-scheme="light"] .flxn-theme-overlay {
background-image: radial-gradient( background-image: radial-gradient(
120% 70% at 50% 0%, 120% 70% at 50% 0%,
rgba(255, 252, 244, 0.65), rgba(255, 252, 244, 0.65),
@@ -221,7 +220,7 @@
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E"); url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E");
} }
[data-theme="clay"][data-mantine-color-scheme="dark"] body::before { [data-theme="clay"][data-mantine-color-scheme="dark"] .flxn-theme-overlay {
background-image: radial-gradient( background-image: radial-gradient(
110% 65% at 50% 0%, 110% 65% at 50% 0%,
rgba(255, 208, 158, 0.09), rgba(255, 208, 158, 0.09),
+1 -2
View File
@@ -363,8 +363,7 @@
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* 4. Sunburst fan — faint gold rays from top center */ /* 4. Sunburst fan — faint gold rays from top center */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
[data-theme="deco"] body::before { [data-theme="deco"] .flxn-theme-overlay {
content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 9998; z-index: 9998;
+1 -2
View File
@@ -409,8 +409,7 @@
* 3. Full-page decoration: candlelit vignette (dark scheme only) * 3. Full-page decoration: candlelit vignette (dark scheme only)
* ---------------------------------------------------------------- */ * ---------------------------------------------------------------- */
[data-theme="gothic"][data-mantine-color-scheme="dark"] body::before { [data-theme="gothic"][data-mantine-color-scheme="dark"] .flxn-theme-overlay {
content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 9998; z-index: 9998;
+58
View File
@@ -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;
}
}
+3 -4
View File
@@ -153,8 +153,7 @@
/* 3. Fixed backdrop (below all content) */ /* 3. Fixed backdrop (below all content) */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
[data-theme="ocean"] body::before { [data-theme="ocean"] .flxn-theme-overlay {
content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: -1; z-index: -1;
@@ -163,7 +162,7 @@
/* Abyssal descent: deep teal fading to ink navy, sparse static bubbles /* Abyssal descent: deep teal fading to ink navy, sparse static bubbles
rising (bigger and brighter near the sea floor, fading toward the top). */ rising (bigger and brighter near the sea floor, fading toward the top). */
[data-theme="ocean"][data-mantine-color-scheme="dark"] body::before { [data-theme="ocean"][data-mantine-color-scheme="dark"] .flxn-theme-overlay {
background-color: #062632; background-color: #062632;
background-image: background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='640' viewBox='0 0 260 640'%3E%3Cg fill='none' stroke='%239beee2'%3E%3Ccircle cx='36' cy='584' r='5' stroke-opacity='.13'/%3E%3Ccircle cx='44' cy='512' r='3' stroke-opacity='.1'/%3E%3Ccircle cx='210' cy='612' r='4' stroke-opacity='.12'/%3E%3Ccircle cx='202' cy='540' r='2.5' stroke-opacity='.09'/%3E%3Ccircle cx='128' cy='430' r='3.5' stroke-opacity='.1'/%3E%3Ccircle cx='120' cy='360' r='2' stroke-opacity='.08'/%3E%3Ccircle cx='232' cy='300' r='3' stroke-opacity='.09'/%3E%3Ccircle cx='60' cy='250' r='2.5' stroke-opacity='.08'/%3E%3Ccircle cx='150' cy='160' r='2' stroke-opacity='.07'/%3E%3Ccircle cx='90' cy='80' r='1.5' stroke-opacity='.06'/%3E%3C/g%3E%3Cg fill='%239beee2'%3E%3Ccircle cx='70' cy='470' r='1.2' fill-opacity='.1'/%3E%3Ccircle cx='180' cy='390' r='1' fill-opacity='.09'/%3E%3Ccircle cx='40' cy='330' r='1' fill-opacity='.08'/%3E%3Ccircle cx='222' cy='190' r='1' fill-opacity='.07'/%3E%3Ccircle cx='140' cy='40' r='1' fill-opacity='.05'/%3E%3C/g%3E%3C/svg%3E"), url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='640' viewBox='0 0 260 640'%3E%3Cg fill='none' stroke='%239beee2'%3E%3Ccircle cx='36' cy='584' r='5' stroke-opacity='.13'/%3E%3Ccircle cx='44' cy='512' r='3' stroke-opacity='.1'/%3E%3Ccircle cx='210' cy='612' r='4' stroke-opacity='.12'/%3E%3Ccircle cx='202' cy='540' r='2.5' stroke-opacity='.09'/%3E%3Ccircle cx='128' cy='430' r='3.5' stroke-opacity='.1'/%3E%3Ccircle cx='120' cy='360' r='2' stroke-opacity='.08'/%3E%3Ccircle cx='232' cy='300' r='3' stroke-opacity='.09'/%3E%3Ccircle cx='60' cy='250' r='2.5' stroke-opacity='.08'/%3E%3Ccircle cx='150' cy='160' r='2' stroke-opacity='.07'/%3E%3Ccircle cx='90' cy='80' r='1.5' stroke-opacity='.06'/%3E%3C/g%3E%3Cg fill='%239beee2'%3E%3Ccircle cx='70' cy='470' r='1.2' fill-opacity='.1'/%3E%3Ccircle cx='180' cy='390' r='1' fill-opacity='.09'/%3E%3Ccircle cx='40' cy='330' r='1' fill-opacity='.08'/%3E%3Ccircle cx='222' cy='190' r='1' fill-opacity='.07'/%3E%3Ccircle cx='140' cy='40' r='1' fill-opacity='.05'/%3E%3C/g%3E%3C/svg%3E"),
@@ -175,7 +174,7 @@
/* Sunlit shallows: seafoam fading to a sandy floor, with a faint white /* Sunlit shallows: seafoam fading to a sandy floor, with a faint white
caustic ripple tile — refracted light, kept very subtle. */ caustic ripple tile — refracted light, kept very subtle. */
[data-theme="ocean"][data-mantine-color-scheme="light"] body::before { [data-theme="ocean"][data-mantine-color-scheme="light"] .flxn-theme-overlay {
background-color: #eaf6f6; background-color: #eaf6f6;
background-image: background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='320' height='220' viewBox='0 0 320 220'%3E%3Cg fill='none' stroke='%23ffffff' stroke-width='1.4'%3E%3Cpath d='M0 40 Q 40 10 80 42 T 160 44 T 240 38 T 320 42' stroke-opacity='.4'/%3E%3Cpath d='M0 110 Q 45 82 90 112 T 180 110 T 268 114 T 320 108' stroke-opacity='.3'/%3E%3Cpath d='M0 180 Q 50 152 100 182 T 200 178 T 300 184 T 320 180' stroke-opacity='.35'/%3E%3C/g%3E%3C/svg%3E"), url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='320' height='220' viewBox='0 0 320 220'%3E%3Cg fill='none' stroke='%23ffffff' stroke-width='1.4'%3E%3Cpath d='M0 40 Q 40 10 80 42 T 160 44 T 240 38 T 320 42' stroke-opacity='.4'/%3E%3Cpath d='M0 110 Q 45 82 90 112 T 180 110 T 268 114 T 320 108' stroke-opacity='.3'/%3E%3Cpath d='M0 180 Q 50 152 100 182 T 200 178 T 300 184 T 320 180' stroke-opacity='.35'/%3E%3C/g%3E%3C/svg%3E"),
+2 -4
View File
@@ -414,8 +414,7 @@
* 3. Full-page decoration: worn parchment edges / torchlight * 3. Full-page decoration: worn parchment edges / torchlight
* ---------------------------------------------------------------- */ * ---------------------------------------------------------------- */
[data-theme="quest"][data-mantine-color-scheme="light"] body::before { [data-theme="quest"][data-mantine-color-scheme="light"] .flxn-theme-overlay {
content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 9998; z-index: 9998;
@@ -426,8 +425,7 @@
radial-gradient(55% 38% at 95% 98%, rgba(122, 91, 42, 0.07), transparent 70%); radial-gradient(55% 38% at 95% 98%, rgba(122, 91, 42, 0.07), transparent 70%);
} }
[data-theme="quest"][data-mantine-color-scheme="dark"] body::before { [data-theme="quest"][data-mantine-color-scheme="dark"] .flxn-theme-overlay {
content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 9998; z-index: 9998;
+2 -3
View File
@@ -151,8 +151,7 @@
/* 3. Paper grain / chalk dust — one fixed overlay, feTurbulence */ /* 3. Paper grain / chalk dust — one fixed overlay, feTurbulence */
/* ---------------------------------------------------------------- */ /* ---------------------------------------------------------------- */
[data-theme="sketch"] body::before { [data-theme="sketch"] .flxn-theme-overlay {
content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 9998; z-index: 9998;
@@ -163,7 +162,7 @@
opacity: 0.05; opacity: 0.05;
} }
[data-theme="sketch"][data-mantine-color-scheme="dark"] body::before { [data-theme="sketch"][data-mantine-color-scheme="dark"] .flxn-theme-overlay {
mix-blend-mode: screen; mix-blend-mode: screen;
opacity: 0.07; opacity: 0.07;
} }
+2 -3
View File
@@ -575,8 +575,7 @@
} }
/* 4. Decoration: fixed scanline overlay + faint RGB aperture mask */ /* 4. Decoration: fixed scanline overlay + faint RGB aperture mask */
[data-theme="terminal"] body::before { [data-theme="terminal"] .flxn-theme-overlay {
content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 9998; z-index: 9998;
@@ -589,7 +588,7 @@
} }
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
[data-theme="terminal"] body::before { [data-theme="terminal"] .flxn-theme-overlay {
animation: terminal-phosphor-drift 9s ease-in-out infinite; animation: terminal-phosphor-drift 9s ease-in-out infinite;
} }
@keyframes terminal-phosphor-drift { @keyframes terminal-phosphor-drift {
+1 -2
View File
@@ -150,8 +150,7 @@
/* ---------- 3. Decoration: static perspective floor-grid hint ---------- */ /* ---------- 3. Decoration: static perspective floor-grid hint ---------- */
[data-theme="wireframe"] body::before { [data-theme="wireframe"] .flxn-theme-overlay {
content: "";
position: fixed; position: fixed;
left: -30vw; left: -30vw;
right: -30vw; right: -30vw;