feat(web): redesign navigation with centered logo and smooth page transitions

- Replace sidebar nav with centered header featuring PAC-MAN title
- Add glimmer animation effect for non-active logo state
- Implement 200ms fade transitions between pages
- Add custom scrollbar styling with OverlayScrollbars
- Switch to Russo One font for title, Outfit for body text
This commit is contained in:
2025-12-29 02:53:55 -06:00
parent 5e86bbb040
commit 65a9c6bab9
10 changed files with 3992 additions and 56 deletions
+3
View File
@@ -7,6 +7,7 @@
"dependencies": { "dependencies": {
"@fontsource/nunito": "^5.2.7", "@fontsource/nunito": "^5.2.7",
"@fontsource/pixelify-sans": "^5.2.7", "@fontsource/pixelify-sans": "^5.2.7",
"@fontsource/russo-one": "^5.2.7",
"@tabler/icons-react": "^3.35.0", "@tabler/icons-react": "^3.35.0",
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.2",
"react": "^19.1.1", "react": "^19.1.1",
@@ -157,6 +158,8 @@
"@fontsource/pixelify-sans": ["@fontsource/pixelify-sans@5.2.7", "", {}, "sha512-F/UuV2M9poAj/BJ5/6u95mOy6ptp8/+dpfnxh4TFzKeAB+vdanAjQ8fLJcS0q+WHYgesRRxPwNFr2Pqm18CVGg=="], "@fontsource/pixelify-sans": ["@fontsource/pixelify-sans@5.2.7", "", {}, "sha512-F/UuV2M9poAj/BJ5/6u95mOy6ptp8/+dpfnxh4TFzKeAB+vdanAjQ8fLJcS0q+WHYgesRRxPwNFr2Pqm18CVGg=="],
"@fontsource/russo-one": ["@fontsource/russo-one@5.2.7", "", {}, "sha512-/VyiuTzAFUjxwpJdtwR9ByC9BggUFWS5Hw/JT8ziEtNAd++BNfh/fDrKCC6oayFG69gfP0zhS5+0UenYcOK3UQ=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
+73 -19
View File
@@ -1,11 +1,14 @@
import "./tailwind.css"; import "./tailwind.css";
import "@fontsource/pixelify-sans"; import "@fontsource/pixelify-sans";
import "@fontsource/nunito/800.css"; import "@fontsource/outfit/400.css";
import "@fontsource/nunito"; import "@fontsource/outfit/500.css";
import "@fontsource/russo-one";
import "overlayscrollbars/overlayscrollbars.css";
import { useState } from "react"; import { useState, useEffect } from "react";
import { usePageContext } from "vike-react/usePageContext"; import { usePageContext } from "vike-react/usePageContext";
import { IconBrandGithub, IconDownload, IconDeviceGamepad3, IconTrophy } from "@tabler/icons-react"; import { IconBrandGithub, IconDownload, IconDeviceGamepad3, IconTrophy } from "@tabler/icons-react";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
const links = [ const links = [
{ {
@@ -30,13 +33,25 @@ const links = [
}, },
]; ];
export function Link({ href, label }: { href: string; label: string }) { export function Link({ href, label, icon }: { href: string; label: string; icon?: React.ReactNode }) {
const pageContext = usePageContext(); const pageContext = usePageContext();
const { urlPathname } = pageContext; const { urlPathname } = pageContext;
const isActive = href === "/" ? urlPathname === href : urlPathname.startsWith(href); const isActive = href === "/" ? urlPathname === href : urlPathname.startsWith(href);
return ( return (
<a href={href} className={isActive ? "text-yellow-400" : "text-gray-400"}> <a
{label} href={href}
className={`
flex items-center gap-1.5
tracking-wide
transition-colors duration-200
${isActive
? "text-white"
: "text-gray-500 hover:text-gray-300"
}
`}
>
{icon}
<span>{label}</span>
</a> </a>
); );
} }
@@ -46,9 +61,11 @@ export default function LayoutDefault({ children }: { children: React.ReactNode
const toggle = () => setOpened((v) => !v); const toggle = () => setOpened((v) => !v);
const close = () => setOpened(false); const close = () => setOpened(false);
const mainLinks = links const pageContext = usePageContext();
.filter((link) => link.href.startsWith("/")) const { urlPathname } = pageContext;
.map((link) => <Link href={link.href} key={link.label} label={link.label} />); const isIndexPage = urlPathname === "/";
const isLeaderboardPage = urlPathname.startsWith("/leaderboard");
const isDownloadPage = urlPathname.startsWith("/download");
const sourceLinks = links const sourceLinks = links
.filter((link) => !link.href.startsWith("/")) .filter((link) => !link.href.startsWith("/"))
@@ -58,31 +75,68 @@ export default function LayoutDefault({ children }: { children: React.ReactNode
title={link.label} title={link.label}
key={link.label} key={link.label}
target="_blank" target="_blank"
className="transition-[drop-shadow] duration-1000 hover:drop-shadow-sm drop-shadow-yellow-400" className="text-gray-500 hover:text-gray-300 transition-colors duration-200"
> >
{link.icon} {link.icon}
</a> </a>
)); ));
return ( return (
<div className="bg-black text-yellow-400 min-h-screen flex flex-col"> <div className="bg-black text-yellow-400 h-screen flex flex-col overflow-hidden">
<header className="sticky top-0 z-20 h-[60px] border-b border-yellow-400/25 bg-black"> <header className="shrink-0 h-[60px] border-b border-yellow-400/25 bg-black z-20">
<div className="h-full px-4 flex items-center justify-between"> <div className="h-full px-4 flex items-center justify-center">
<div className="flex items-center gap-3">
<button <button
aria-label="Open navigation" aria-label="Open navigation"
onClick={toggle} onClick={toggle}
className="sm:hidden inline-flex items-center justify-center w-9 h-9 rounded border border-yellow-400/30 text-yellow-400" className="sm:hidden absolute left-4 inline-flex items-center justify-center w-9 h-9 rounded border border-yellow-400/30 text-yellow-400"
> >
<span className="sr-only">Toggle menu</span> <span className="sr-only">Toggle menu</span>
<div className="w-5 h-0.5 bg-yellow-400" /> <div className="w-5 h-0.5 bg-yellow-400" />
</button> </button>
<nav className="hidden sm:flex gap-4 items-center">{mainLinks}</nav>
<div className="flex items-center gap-8">
<Link href="/leaderboard" label="Leaderboard" icon={<IconTrophy size={18} />} />
<a
href="/"
onClick={(e) => {
if (isIndexPage) {
e.preventDefault();
}
}}
>
<h1
className={`text-3xl tracking-[0.3em] title-hover ${
isIndexPage
? 'text-yellow-400'
: 'glimmer-text'
}`}
style={{ fontFamily: 'Russo One' }}
>
PAC-MAN
</h1>
</a>
<Link href="/download" label="Download" icon={<IconDownload size={18} />} />
</div> </div>
<div className="hidden sm:flex gap-4 items-center">{sourceLinks}</div>
<div className="absolute right-4 hidden sm:flex gap-4 items-center">{sourceLinks}</div>
</div> </div>
</header> </header>
<main className="flex-1">{children}</main>
<OverlayScrollbarsComponent
defer
options={{
scrollbars: {
theme: 'os-theme-light',
autoHide: 'scroll',
autoHideDelay: 1300,
},
}}
className="flex-1"
>
<main>{children}</main>
</OverlayScrollbarsComponent>
{opened && ( {opened && (
<div className="fixed inset-0 z-30"> <div className="fixed inset-0 z-30">
@@ -100,7 +154,7 @@ export default function LayoutDefault({ children }: { children: React.ReactNode
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{links.map((link) => ( {links.map((link) => (
<Link href={link.href} key={link.label} label={link.label} /> <Link href={link.href} key={link.label} label={link.label} icon={link.icon} />
))} ))}
</div> </div>
</div> </div>
+70 -2
View File
@@ -1,9 +1,26 @@
@import "tailwindcss"; @import "tailwindcss";
@layer base { @layer base {
/* Page transitions */
body {
--transition-duration: 200ms;
}
body main {
opacity: 1;
transform: translateY(0);
transition:
opacity var(--transition-duration) ease-out,
transform var(--transition-duration) ease-out;
}
body.page-is-transitioning main {
opacity: 0;
transform: translateY(8px);
}
:root { :root {
font-family: font-family:
"Nunito", "Outfit",
ui-sans-serif, ui-sans-serif,
system-ui, system-ui,
-apple-system, -apple-system,
@@ -24,6 +41,57 @@
h4, h4,
h5, h5,
h6 { h6 {
font-weight: 800; font-weight: 500;
}
.os-scrollbar-handle {
background: rgb(250 204 21 / 0.25) !important;
}
.os-scrollbar-handle:hover {
background: rgb(250 204 21 / 0.4) !important;
}
.os-scrollbar-track {
background: transparent !important;
}
}
@keyframes glimmer {
0% {
background-position: -200% center;
}
100% {
background-position: 200% center;
}
}
@layer utilities {
.title-hover {
transition: transform 0.2s ease-out, filter 0.2s ease-out;
}
.title-hover:hover {
transform: scale(1.03);
filter: brightness(1.15);
}
.glimmer-text {
background: linear-gradient(
90deg,
rgb(156 163 175) 0%,
rgb(156 163 175) 35%,
rgb(250 204 21) 45%,
rgb(250 204 21) 55%,
rgb(156 163 175) 65%,
rgb(156 163 175) 100%
);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: glimmer 3s ease-in-out infinite;
} }
} }
+4
View File
@@ -10,9 +10,13 @@
"description": "A web frontend for the Pac-Man game, including leaderboards and OAuth.", "description": "A web frontend for the Pac-Man game, including leaderboards and OAuth.",
"dependencies": { "dependencies": {
"@fontsource/nunito": "^5.2.7", "@fontsource/nunito": "^5.2.7",
"@fontsource/outfit": "^5.2.8",
"@fontsource/pixelify-sans": "^5.2.7", "@fontsource/pixelify-sans": "^5.2.7",
"@fontsource/russo-one": "^5.2.7",
"@tabler/icons-react": "^3.35.0", "@tabler/icons-react": "^3.35.0",
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^5.0.2",
"overlayscrollbars": "^2.13.0",
"overlayscrollbars-react": "^0.5.6",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"vike": "^0.4.240", "vike": "^0.4.240",
+12 -2
View File
@@ -9,6 +9,17 @@ export const onPageTransitionEnd: OnPageTransitionEndAsync = async (
// Restart the game loop when returning to the game page // Restart the game loop when returning to the game page
if (pageContext.urlPathname === "/") { if (pageContext.urlPathname === "/") {
// Defer game restart to allow fade-in animation to complete first
// This prevents the heavy WebGL initialization from blocking the UI
requestAnimationFrame(() => {
setTimeout(() => {
restartGame();
}, 0);
});
}
};
function restartGame() {
const win = getPacmanWindow(); const win = getPacmanWindow();
const module = win.Module; const module = win.Module;
@@ -31,5 +42,4 @@ export const onPageTransitionEnd: OnPageTransitionEndAsync = async (
console.error("Failed to restart game:", error); console.error("Failed to restart game:", error);
} }
} }
} }
};
+5
View File
@@ -1,6 +1,8 @@
import type { OnPageTransitionStartAsync } from "vike/types"; import type { OnPageTransitionStartAsync } from "vike/types";
import { getPacmanWindow } from "@/lib/pacman"; import { getPacmanWindow } from "@/lib/pacman";
const TRANSITION_DURATION = 200;
export const onPageTransitionStart: OnPageTransitionStartAsync = async () => { export const onPageTransitionStart: OnPageTransitionStartAsync = async () => {
console.log("Page transition start"); console.log("Page transition start");
document.querySelector("body")?.classList.add("page-is-transitioning"); document.querySelector("body")?.classList.add("page-is-transitioning");
@@ -11,4 +13,7 @@ export const onPageTransitionStart: OnPageTransitionStartAsync = async () => {
console.log("Stopping game loop for page transition"); console.log("Stopping game loop for page transition");
win.Module._stop_game(); win.Module._stop_game();
} }
// Wait for fade-out animation to complete before page content changes
await new Promise((resolve) => setTimeout(resolve, TRANSITION_DURATION));
}; };
+14
View File
@@ -0,0 +1,14 @@
export default function Page() {
return (
<div className="mx-auto max-w-3xl py-8 px-4">
<div className="space-y-6">
<div className="border border-yellow-400/20 rounded-md bg-transparent p-6 shadow-[0_4px_20px_rgba(250,204,21,0.08)]">
<h2 className="text-2xl font-bold mb-4">Download Pac-Man</h2>
<p className="text-gray-300 mb-4">
Download instructions and releases will be available here soon.
</p>
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -2,8 +2,8 @@ import "../../layouts/tailwind.css";
export default function GameLayout({ children }: { children: React.ReactNode }) { export default function GameLayout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="bg-black text-yellow-400 min-h-screen flex flex-col"> <div className="bg-black text-yellow-400 h-full flex flex-col overflow-hidden">
<main className="flex-1">{children}</main> <main className="flex-1 overflow-hidden">{children}</main>
</div> </div>
); );
} }
+3 -4
View File
@@ -71,12 +71,11 @@ export default function Page() {
}, [gameReady, gameStarted, handleInteraction]); }, [gameReady, gameStarted, handleInteraction]);
return ( return (
<div className="mt-4 flex justify-center h-[calc(100vh-120px)]"> <div className="flex justify-center items-center h-full pt-4">
<div <div
className="relative block border-1 border-yellow-400/50 aspect-[5/6] h-[min(calc(100vh-120px),_calc(95vw_*_6/5))] w-auto" className="relative block aspect-[5/6]"
style={{ style={{
boxShadow: height: "min(calc(100vh - 96px), calc((100vw - 32px) * 6 / 5))",
"0 0 12px rgba(250, 204, 21, 0.35), 0 0 2px rgba(255, 255, 255, 0.25)",
}} }}
onClick={handleInteraction} onClick={handleInteraction}
> >
+3779
View File
File diff suppressed because it is too large Load Diff