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": {
"@fontsource/nunito": "^5.2.7",
"@fontsource/pixelify-sans": "^5.2.7",
"@fontsource/russo-one": "^5.2.7",
"@tabler/icons-react": "^3.35.0",
"@vitejs/plugin-react": "^5.0.2",
"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/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/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 "@fontsource/pixelify-sans";
import "@fontsource/nunito/800.css";
import "@fontsource/nunito";
import "@fontsource/outfit/400.css";
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 { IconBrandGithub, IconDownload, IconDeviceGamepad3, IconTrophy } from "@tabler/icons-react";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
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 { urlPathname } = pageContext;
const isActive = href === "/" ? urlPathname === href : urlPathname.startsWith(href);
return (
<a href={href} className={isActive ? "text-yellow-400" : "text-gray-400"}>
{label}
<a
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>
);
}
@@ -46,9 +61,11 @@ export default function LayoutDefault({ children }: { children: React.ReactNode
const toggle = () => setOpened((v) => !v);
const close = () => setOpened(false);
const mainLinks = links
.filter((link) => link.href.startsWith("/"))
.map((link) => <Link href={link.href} key={link.label} label={link.label} />);
const pageContext = usePageContext();
const { urlPathname } = pageContext;
const isIndexPage = urlPathname === "/";
const isLeaderboardPage = urlPathname.startsWith("/leaderboard");
const isDownloadPage = urlPathname.startsWith("/download");
const sourceLinks = links
.filter((link) => !link.href.startsWith("/"))
@@ -58,31 +75,68 @@ export default function LayoutDefault({ children }: { children: React.ReactNode
title={link.label}
key={link.label}
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}
</a>
));
return (
<div className="bg-black text-yellow-400 min-h-screen flex flex-col">
<header className="sticky top-0 z-20 h-[60px] border-b border-yellow-400/25 bg-black">
<div className="h-full px-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-black text-yellow-400 h-screen flex flex-col overflow-hidden">
<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-center">
<button
aria-label="Open navigation"
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>
<div className="w-5 h-0.5 bg-yellow-400" />
</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 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>
</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 && (
<div className="fixed inset-0 z-30">
@@ -100,7 +154,7 @@ export default function LayoutDefault({ children }: { children: React.ReactNode
</div>
<div className="flex flex-col gap-3">
{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>
+70 -2
View File
@@ -1,9 +1,26 @@
@import "tailwindcss";
@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 {
font-family:
"Nunito",
"Outfit",
ui-sans-serif,
system-ui,
-apple-system,
@@ -24,6 +41,57 @@
h4,
h5,
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.",
"dependencies": {
"@fontsource/nunito": "^5.2.7",
"@fontsource/outfit": "^5.2.8",
"@fontsource/pixelify-sans": "^5.2.7",
"@fontsource/russo-one": "^5.2.7",
"@tabler/icons-react": "^3.35.0",
"@vitejs/plugin-react": "^5.0.2",
"overlayscrollbars": "^2.13.0",
"overlayscrollbars-react": "^0.5.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"vike": "^0.4.240",
+11 -1
View File
@@ -9,6 +9,17 @@ export const onPageTransitionEnd: OnPageTransitionEndAsync = async (
// Restart the game loop when returning to the game page
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 module = win.Module;
@@ -32,4 +43,3 @@ export const onPageTransitionEnd: OnPageTransitionEndAsync = async (
}
}
}
};
+5
View File
@@ -1,6 +1,8 @@
import type { OnPageTransitionStartAsync } from "vike/types";
import { getPacmanWindow } from "@/lib/pacman";
const TRANSITION_DURATION = 200;
export const onPageTransitionStart: OnPageTransitionStartAsync = async () => {
console.log("Page transition start");
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");
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 }) {
return (
<div className="bg-black text-yellow-400 min-h-screen flex flex-col">
<main className="flex-1">{children}</main>
<div className="bg-black text-yellow-400 h-full flex flex-col overflow-hidden">
<main className="flex-1 overflow-hidden">{children}</main>
</div>
);
}
+3 -4
View File
@@ -71,12 +71,11 @@ export default function Page() {
}, [gameReady, gameStarted, handleInteraction]);
return (
<div className="mt-4 flex justify-center h-[calc(100vh-120px)]">
<div className="flex justify-center items-center h-full pt-4">
<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={{
boxShadow:
"0 0 12px rgba(250, 204, 21, 0.35), 0 0 2px rgba(255, 255, 255, 0.25)",
height: "min(calc(100vh - 96px), calc((100vw - 32px) * 6 / 5))",
}}
onClick={handleInteraction}
>
+3779
View File
File diff suppressed because it is too large Load Diff