Deux courbes partagées dans lib/motion.ts, pour que le CSS et Framer aient le même feeling, et quatre primitives que Tailwind ne sait pas exprimer : reflet de bouton, halo qui suit le curseur, soulignement qui se dessine, secousse d'erreur. Nouveaux composants : Stagger (les collections se distribuent une par une), Spotlight (trouve son parent tout seul, donc les cartes restent des composants serveur), ScrollProgress, BackToTop, Parallax, Spinner, CheckMark. Reveal accepte maintenant une direction. Côté rendu : le trait de nav glisse d'un onglet à l'autre, les cartes se soulèvent, le hero réagit au scroll, les piliers entrent en zig-zag, la lightbox glisse dans le sens demandé et les formulaires répondent. prefers-reduced-motion est respecté partout : Framer coupe via useReducedMotion, le CSS via la media query déjà en place. Le commit embarque aussi la réorganisation des composants par domaine qui était en cours dans l'arbre de travail — les deux étaient trop imbriquées pour être séparées proprement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,141 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { sendContact, type ContactState } from "@/app/actions/contact";
|
||||
|
||||
const initialState: ContactState = { status: "idle", message: "" };
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="px-8 py-3 bg-accent text-background font-display tracking-widest text-sm hover:bg-foreground hover:scale-[1.03] transition duration-200 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
>
|
||||
{pending ? "SENDING…" : "SEND MESSAGE"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const inputBase =
|
||||
"w-full bg-surface border px-4 py-3 text-sm text-foreground placeholder:text-foreground-muted focus:outline-none transition-colors";
|
||||
|
||||
export function ContactForm() {
|
||||
const [state, formAction] = useActionState(sendContact, initialState);
|
||||
|
||||
if (state.status === "success") {
|
||||
return (
|
||||
<div className="border border-accent/40 bg-surface p-8 text-center">
|
||||
<p className="font-display text-xs tracking-[0.3em] text-accent mb-3">
|
||||
MESSAGE SENT
|
||||
</p>
|
||||
<p className="text-foreground-muted leading-relaxed">{state.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const err = state.fieldErrors ?? {};
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-5">
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
className="hidden"
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="contact-name"
|
||||
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
||||
>
|
||||
NAME
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Your name"
|
||||
className={`${inputBase} ${err.name ? "border-red-400" : "border-border focus:border-accent"}`}
|
||||
/>
|
||||
{err.name && (
|
||||
<p className="text-xs text-red-400 mt-1.5">{err.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="contact-email"
|
||||
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
||||
>
|
||||
EMAIL
|
||||
</label>
|
||||
<input
|
||||
id="contact-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="you@example.com"
|
||||
className={`${inputBase} ${err.email ? "border-red-400" : "border-border focus:border-accent"}`}
|
||||
/>
|
||||
{err.email && (
|
||||
<p className="text-xs text-red-400 mt-1.5">{err.email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="contact-subject"
|
||||
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
||||
>
|
||||
SUBJECT
|
||||
</label>
|
||||
<input
|
||||
id="contact-subject"
|
||||
name="subject"
|
||||
type="text"
|
||||
required
|
||||
placeholder="What is this about?"
|
||||
className={`${inputBase} ${err.subject ? "border-red-400" : "border-border focus:border-accent"}`}
|
||||
/>
|
||||
{err.subject && (
|
||||
<p className="text-xs text-red-400 mt-1.5">{err.subject}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="contact-message"
|
||||
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
||||
>
|
||||
MESSAGE
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
name="message"
|
||||
rows={6}
|
||||
required
|
||||
placeholder="Your message…"
|
||||
className={`${inputBase} resize-y ${err.message ? "border-red-400" : "border-border focus:border-accent"}`}
|
||||
/>
|
||||
{err.message && (
|
||||
<p className="text-xs text-red-400 mt-1.5">{err.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pt-2">
|
||||
<SubmitButton />
|
||||
{state.status === "error" && !Object.keys(err).length && (
|
||||
<p className="text-sm text-red-400">{state.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { SocialLinks } from "./SocialLinks";
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-border mt-24">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-10 py-12 grid grid-cols-1 md:grid-cols-4 gap-10">
|
||||
<div className="md:col-span-2">
|
||||
<Link href="/" className="group inline-flex items-center gap-3">
|
||||
<Image
|
||||
src="/image/Studio/Logo Flat.png"
|
||||
alt="Highland Games Studio"
|
||||
width={32}
|
||||
height={32}
|
||||
className="transition-transform duration-300 group-hover:scale-110 group-hover:-translate-y-0.5"
|
||||
/>
|
||||
<span className="font-display text-lg tracking-wider transition-colors duration-300 group-hover:text-accent">
|
||||
Highland Games Studio
|
||||
</span>
|
||||
</Link>
|
||||
<p className="mt-4 text-sm text-foreground-muted max-w-sm">
|
||||
Where summits become worlds. An indie studio crafting survival
|
||||
adventures.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-display text-sm tracking-widest text-foreground-muted mb-4">
|
||||
Explore
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link
|
||||
href="/studio"
|
||||
className="group relative inline-block text-foreground-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
Studio
|
||||
<span className="absolute left-0 -bottom-0.5 h-px w-0 bg-accent transition-all duration-300 group-hover:w-full" />
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/games"
|
||||
className="group relative inline-block text-foreground-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
Games
|
||||
<span className="absolute left-0 -bottom-0.5 h-px w-0 bg-accent transition-all duration-300 group-hover:w-full" />
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/devblog"
|
||||
className="group relative inline-block text-foreground-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
Devblog
|
||||
<span className="absolute left-0 -bottom-0.5 h-px w-0 bg-accent transition-all duration-300 group-hover:w-full" />
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="group relative inline-block text-foreground-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
Contact
|
||||
<span className="absolute left-0 -bottom-0.5 h-px w-0 bg-accent transition-all duration-300 group-hover:w-full" />
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-display text-sm tracking-widest text-foreground-muted mb-4">
|
||||
Follow
|
||||
</h4>
|
||||
<SocialLinks />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-10 py-6 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-foreground-muted">
|
||||
<p>© {new Date().getFullYear()} Highland Games Studio. All rights reserved.</p>
|
||||
<div className="flex items-center gap-5">
|
||||
<Link
|
||||
href="/legal/notice"
|
||||
className="hover:text-accent transition-colors"
|
||||
>
|
||||
Legal Notice
|
||||
</Link>
|
||||
<Link
|
||||
href="/legal/privacy"
|
||||
className="hover:text-accent transition-colors"
|
||||
>
|
||||
Privacy
|
||||
</Link>
|
||||
</div>
|
||||
<p className="font-display tracking-widest hidden lg:block">WHERE SUMMITS BECOME WORLDS</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
const links = [
|
||||
{ href: "/studio", label: "Studio" },
|
||||
{ href: "/games", label: "Games" },
|
||||
{ href: "/devblog", label: "Devblog" },
|
||||
{ href: "/contact", label: "Contact" },
|
||||
];
|
||||
|
||||
export function Navbar() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 20);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<motion.header
|
||||
initial={{ y: -20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
scrolled || open
|
||||
? "bg-background/80 backdrop-blur-md border-b border-border"
|
||||
: "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<nav className="max-w-7xl mx-auto flex items-center justify-between px-6 lg:px-10 h-16 lg:h-20">
|
||||
<Link href="/" className="flex items-center gap-3 group">
|
||||
<Image
|
||||
src="/image/Studio/Logo Flat.png"
|
||||
alt="Highland Games Studio"
|
||||
width={36}
|
||||
height={36}
|
||||
className="transition-transform group-hover:-translate-y-0.5"
|
||||
priority
|
||||
/>
|
||||
<span className="font-display text-xl tracking-wider hidden sm:block">
|
||||
Highland Games
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<ul className="hidden md:flex items-center gap-1 sm:gap-2">
|
||||
{links.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="px-3 py-2 text-sm tracking-wide text-foreground-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Mobile toggle */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="md:hidden relative w-10 h-10 flex items-center justify-center text-foreground hover:text-accent transition-colors"
|
||||
>
|
||||
<span className="sr-only">Menu</span>
|
||||
<span className="relative w-6 h-4 flex flex-col justify-between">
|
||||
<motion.span
|
||||
animate={open ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="block h-px bg-current origin-center"
|
||||
/>
|
||||
<motion.span
|
||||
animate={open ? { opacity: 0 } : { opacity: 1 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="block h-px bg-current"
|
||||
/>
|
||||
<motion.span
|
||||
animate={open ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="block h-px bg-current origin-center"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
id="mobile-menu"
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="md:hidden border-t border-border bg-background/95 backdrop-blur-md"
|
||||
>
|
||||
<ul className="px-6 py-6 flex flex-col gap-1">
|
||||
{links.map((link, i) => (
|
||||
<motion.li
|
||||
key={link.href}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.05 + i * 0.04, duration: 0.3 }}
|
||||
>
|
||||
<Link
|
||||
href={link.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="block py-3 font-display text-2xl tracking-wider text-foreground hover:text-accent transition-colors"
|
||||
>
|
||||
{link.label.toUpperCase()}
|
||||
</Link>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.header>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Social = {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
const socials: Social[] = [
|
||||
{
|
||||
label: "Twitter / X",
|
||||
href: "#",
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Discord",
|
||||
href: "#",
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 0 0-4.8851-1.5152.0741.0741 0 0 0-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 0 0-.0785-.037 19.7363 19.7363 0 0 0-4.8852 1.515.0699.0699 0 0 0-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 0 0 .0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 0 0 .0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 0 0-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 0 1-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 0 1 .0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 0 1 .0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 0 1-.0066.1276 12.2986 12.2986 0 0 1-1.873.8914.0766.0766 0 0 0-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 0 0 .0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 0 0 .0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 0 0-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1569 2.4189Zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "YouTube",
|
||||
href: "#",
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M23.498 6.186a3.02 3.02 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.02 3.02 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.02 3.02 0 0 0 2.122 2.136C4.495 20.455 12 20.455 12 20.455s7.505 0 9.377-.505a3.02 3.02 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Steam",
|
||||
href: "#",
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.456-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function SocialLinks() {
|
||||
return (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{socials.map((s) => (
|
||||
<li key={s.label}>
|
||||
<motion.a
|
||||
href={s.href}
|
||||
initial="rest"
|
||||
whileHover="hover"
|
||||
animate="rest"
|
||||
className="group inline-flex items-center text-foreground-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<motion.span
|
||||
variants={{
|
||||
rest: { width: 0, opacity: 0, marginRight: 0 },
|
||||
hover: { width: 16, opacity: 1, marginRight: 10 },
|
||||
}}
|
||||
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="inline-flex items-center justify-center overflow-hidden text-accent"
|
||||
>
|
||||
<motion.span
|
||||
variants={{
|
||||
rest: { x: -8 },
|
||||
hover: { x: 0 },
|
||||
}}
|
||||
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="block w-4 h-4"
|
||||
>
|
||||
{s.icon}
|
||||
</motion.span>
|
||||
</motion.span>
|
||||
<span>{s.label}</span>
|
||||
</motion.a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Link from "next/link";
|
||||
import { Spotlight } from "../ui/Spotlight";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import type { DevblogPost } from "@/lib/devblog";
|
||||
|
||||
type Props = {
|
||||
post: DevblogPost;
|
||||
/** Small label above the date. */
|
||||
eyebrow?: string;
|
||||
/** Heading level, so the panel fits the page outline. */
|
||||
as?: "h2" | "h3";
|
||||
};
|
||||
|
||||
/**
|
||||
* A single post given real weight, as opposed to a row in `PostList`.
|
||||
*
|
||||
* Used by the home page and at the top of the devblog index: a list of one is
|
||||
* indistinguishable from an empty section, and even with twenty posts the most
|
||||
* recent one deserves more than the same 8rem row as the rest.
|
||||
*/
|
||||
export function PostFeature({ post, eyebrow = "Latest", as: Tag = "h3" }: Props) {
|
||||
return (
|
||||
<Link
|
||||
href={`/devblog/${post.slug}`}
|
||||
className="group relative flex h-full flex-col overflow-hidden rounded-2xl border border-border bg-surface p-8 transition duration-300 ease-out hover:-translate-y-1 hover:border-accent/60 hover:shadow-[0_24px_50px_-30px_rgba(0,0,0,0.9)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent lg:p-10"
|
||||
>
|
||||
<div className="flex items-center gap-4 text-[0.65rem] uppercase tracking-[0.2em] text-foreground-muted">
|
||||
<span className="text-accent">{eyebrow}</span>
|
||||
<time dateTime={post.date}>{formatDate(post.date)}</time>
|
||||
</div>
|
||||
|
||||
<Tag className="mt-5 font-display text-3xl leading-[1.05] tracking-wider transition-colors duration-300 group-hover:text-accent sm:text-4xl">
|
||||
{post.title.toUpperCase()}
|
||||
</Tag>
|
||||
|
||||
{post.excerpt && (
|
||||
<p className="mt-4 leading-relaxed text-foreground-muted">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<span className="mt-8 inline-flex items-center gap-2 font-display text-sm tracking-widest text-accent">
|
||||
READ THE POST
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<Spotlight />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Link from "next/link";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { games } from "@/lib/games";
|
||||
import type { DevblogPost } from "@/lib/devblog";
|
||||
|
||||
type Props = {
|
||||
posts: DevblogPost[];
|
||||
/** Heading level for each entry, so the page outline stays valid. */
|
||||
as?: "h2" | "h3";
|
||||
/** Show the related game as a tag. Off on a game page (redundant there). */
|
||||
showGame?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The devblog entry list. Shared by /devblog, the home page teaser and the
|
||||
* "latest articles" block of a game page, so an entry looks identical
|
||||
* everywhere it appears.
|
||||
*/
|
||||
export function PostList({ posts, as: Tag = "h2", showGame = true }: Props) {
|
||||
return (
|
||||
<ul className="border-t border-border">
|
||||
{posts.map((post) => {
|
||||
const gameTitle = post.game
|
||||
? (games.find((g) => g.slug === post.game)?.title ?? post.game)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<li key={post.slug}>
|
||||
{/* The row is a large target with no border of its own, so the
|
||||
hover has to be built from three small moves rather than one
|
||||
box: a rule that draws down the left edge, the whole block
|
||||
nudged away from it, and the rule under it turning accent. */}
|
||||
<Link
|
||||
href={`/devblog/${post.slug}`}
|
||||
className="group relative block border-b border-border py-8 pl-0 transition-colors duration-300 hover:border-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute bottom-0 left-0 top-0 w-px origin-top scale-y-0 bg-accent transition-transform duration-500 ease-out group-hover:scale-y-100"
|
||||
/>
|
||||
|
||||
<div className="transition-transform duration-300 ease-out group-hover:translate-x-4">
|
||||
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-baseline sm:justify-between">
|
||||
<Tag className="font-display text-2xl tracking-wider transition-colors duration-300 group-hover:text-accent sm:text-3xl">
|
||||
{post.title.toUpperCase()}
|
||||
</Tag>
|
||||
<time
|
||||
dateTime={post.date}
|
||||
className="shrink-0 text-xs tracking-widest text-foreground-muted"
|
||||
>
|
||||
{formatDate(post.date)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{post.excerpt && (
|
||||
<p className="max-w-2xl leading-relaxed text-foreground-muted">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4">
|
||||
<span className="inline-flex items-center gap-2 font-display text-xs tracking-widest text-foreground-muted transition-colors duration-300 group-hover:text-accent">
|
||||
READ
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</span>
|
||||
{showGame && gameTitle && (
|
||||
<span className="border border-accent/40 px-2 py-1 text-[0.65rem] uppercase tracking-widest text-accent transition-colors duration-300 group-hover:border-accent">
|
||||
{gameTitle}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { CheckMark } from "../ui/CheckMark";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
import { sendContact, type ContactState } from "@/app/actions/contact";
|
||||
import { CONTACT_CHANNELS } from "@/lib/site";
|
||||
import { EASE_OUT_EXPO } from "@/lib/motion";
|
||||
|
||||
const initialState: ContactState = { status: "idle", message: "" };
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="sheen relative inline-flex items-center justify-center gap-2 overflow-hidden bg-accent px-8 py-3 font-display text-sm tracking-widest text-background transition duration-200 ease-out hover:-translate-y-0.5 hover:bg-foreground hover:shadow-[0_14px_30px_-14px_rgba(196,184,150,0.6)] active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:translate-y-0 disabled:hover:shadow-none"
|
||||
>
|
||||
{pending && <Spinner />}
|
||||
{pending ? "SENDING…" : "SEND MESSAGE"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const inputBase =
|
||||
"w-full bg-surface border px-4 py-3 text-sm text-foreground placeholder:text-foreground-muted focus:outline-none transition-colors duration-200";
|
||||
|
||||
/** Field errors slide in under their input rather than appearing under it. */
|
||||
function FieldError({ children, id }: { children: string; id: number }) {
|
||||
return (
|
||||
<motion.p
|
||||
key={id}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: EASE_OUT_EXPO }}
|
||||
className="mt-1.5 text-xs text-red-400"
|
||||
>
|
||||
{children}
|
||||
</motion.p>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactForm() {
|
||||
const [state, formAction] = useActionState(sendContact, initialState);
|
||||
|
||||
// A rejected submission has to be felt even when the message is identical to
|
||||
// the last one. React would keep the same paragraph mounted and replay
|
||||
// nothing, so every attempt gets a number and the number is the React key.
|
||||
//
|
||||
// Counted during render by comparing with the previous result rather than in
|
||||
// an effect: the action already returns a fresh object per submission, so
|
||||
// this needs no second render pass to stay in sync.
|
||||
const [seen, setSeen] = useState(state);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
if (seen !== state) {
|
||||
setSeen(state);
|
||||
setAttempt((count) => count + 1);
|
||||
}
|
||||
|
||||
if (state.status === "success") {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.45, ease: EASE_OUT_EXPO }}
|
||||
className="rounded-2xl border border-accent/40 bg-surface p-10 text-center"
|
||||
>
|
||||
<CheckMark className="mx-auto mb-6 h-12 w-12" />
|
||||
<p className="mb-3 font-display text-xs tracking-[0.3em] text-accent">
|
||||
MESSAGE SENT
|
||||
</p>
|
||||
<p className="leading-relaxed text-foreground-muted">{state.message}</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const err = state.fieldErrors ?? {};
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-5">
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
className="hidden"
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="contact-name"
|
||||
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
||||
>
|
||||
NAME
|
||||
</label>
|
||||
<input
|
||||
id="contact-name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="Your name"
|
||||
className={`${inputBase} ${err.name ? "border-red-400" : "border-border focus:border-accent"}`}
|
||||
/>
|
||||
{err.name && <FieldError id={attempt}>{err.name}</FieldError>}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="contact-email"
|
||||
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
||||
>
|
||||
EMAIL
|
||||
</label>
|
||||
<input
|
||||
id="contact-email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="you@example.com"
|
||||
className={`${inputBase} ${err.email ? "border-red-400" : "border-border focus:border-accent"}`}
|
||||
/>
|
||||
{err.email && <FieldError id={attempt}>{err.email}</FieldError>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
{/* The desk the message is for. It replaces the three mailto cards as
|
||||
the default path: the visitor picks a reason instead of guessing
|
||||
which address to write to. */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="contact-topic"
|
||||
className="mb-2 block font-display text-xs tracking-[0.3em] text-foreground-muted"
|
||||
>
|
||||
REASON
|
||||
</label>
|
||||
<select
|
||||
id="contact-topic"
|
||||
name="topic"
|
||||
defaultValue={CONTACT_CHANNELS[0].label}
|
||||
className={`${inputBase} cursor-pointer border-border focus:border-accent`}
|
||||
>
|
||||
{CONTACT_CHANNELS.map((channel) => (
|
||||
<option key={channel.label} value={channel.label}>
|
||||
{channel.label.charAt(0) + channel.label.slice(1).toLowerCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="contact-subject"
|
||||
className="mb-2 block font-display text-xs tracking-[0.3em] text-foreground-muted"
|
||||
>
|
||||
SUBJECT
|
||||
</label>
|
||||
<input
|
||||
id="contact-subject"
|
||||
name="subject"
|
||||
type="text"
|
||||
required
|
||||
placeholder="What is this about?"
|
||||
className={`${inputBase} ${err.subject ? "border-red-400" : "border-border focus:border-accent"}`}
|
||||
/>
|
||||
{err.subject && <FieldError id={attempt}>{err.subject}</FieldError>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="contact-message"
|
||||
className="block font-display text-xs tracking-[0.3em] text-foreground-muted mb-2"
|
||||
>
|
||||
MESSAGE
|
||||
</label>
|
||||
<textarea
|
||||
id="contact-message"
|
||||
name="message"
|
||||
rows={6}
|
||||
required
|
||||
placeholder="Your message…"
|
||||
className={`${inputBase} resize-y ${err.message ? "border-red-400" : "border-border focus:border-accent"}`}
|
||||
/>
|
||||
{err.message && <FieldError id={attempt}>{err.message}</FieldError>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pt-2">
|
||||
<SubmitButton />
|
||||
{state.status === "error" && !Object.keys(err).length && (
|
||||
// Shakes once per attempt. The key remounts it, which is what makes
|
||||
// the CSS animation run again on an identical repeated error.
|
||||
<p key={attempt} className="animate-shake text-sm text-red-400">
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
import { subscribe, type SubscribeState } from "@/app/actions/subscribe";
|
||||
import { EASE_OUT_EXPO } from "@/lib/motion";
|
||||
|
||||
const initialState: SubscribeState = { status: "idle", message: "" };
|
||||
|
||||
@@ -12,8 +15,9 @@ function SubmitButton() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="px-6 py-3 bg-accent text-background font-display tracking-widest text-sm hover:bg-foreground hover:scale-[1.03] transition duration-200 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
className="sheen relative inline-flex shrink-0 items-center justify-center gap-2 overflow-hidden bg-accent px-6 py-3 font-display text-sm tracking-widest text-background transition duration-200 ease-out hover:-translate-y-0.5 hover:bg-foreground hover:shadow-[0_14px_30px_-14px_rgba(196,184,150,0.6)] active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:translate-y-0 disabled:hover:shadow-none"
|
||||
>
|
||||
{pending && <Spinner />}
|
||||
{pending ? "SENDING…" : "SUBSCRIBE"}
|
||||
</button>
|
||||
);
|
||||
@@ -56,11 +60,14 @@ export function Newsletter({ variant = "default" }: Props) {
|
||||
name="email"
|
||||
required
|
||||
placeholder="your@email.com"
|
||||
className="flex-1 min-w-0 bg-surface border border-border px-4 py-3 text-sm text-foreground placeholder:text-foreground-muted focus:outline-none focus:border-accent transition-colors"
|
||||
className="flex-1 min-w-0 bg-surface border border-border px-4 py-3 text-sm text-foreground placeholder:text-foreground-muted focus:outline-none focus:border-accent transition-colors duration-200"
|
||||
/>
|
||||
<SubmitButton />
|
||||
</div>
|
||||
|
||||
{/* The paragraph itself never unmounts — a live region that is replaced
|
||||
on every state change is one a screen reader may not announce. Only
|
||||
the text inside it is keyed, and that is what animates. */}
|
||||
<p
|
||||
id="newsletter-status"
|
||||
role="status"
|
||||
@@ -75,8 +82,16 @@ export function Newsletter({ variant = "default" }: Props) {
|
||||
: "text-foreground-muted"
|
||||
}`}
|
||||
>
|
||||
{state.message ||
|
||||
"No spam. One devblog email per major update. Unsubscribe anytime."}
|
||||
<motion.span
|
||||
key={state.message || "idle"}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
|
||||
className="inline-block"
|
||||
>
|
||||
{state.message ||
|
||||
"No spam. One devblog email per major update. Unsubscribe anytime."}
|
||||
</motion.span>
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Reveal } from "../ui/Reveal";
|
||||
import { CountUp } from "../ui/CountUp";
|
||||
|
||||
type Props = {
|
||||
/** e.g. "1-6". Rendered as "1–6" with a real en dash. */
|
||||
players: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The one number worth shouting.
|
||||
*
|
||||
* A player count buried in a fact sheet is a specification; at this size it is
|
||||
* an argument. Borrowed from the EmberWild site's co-op band — the page needs
|
||||
* one moment where the type does the talking instead of a paragraph.
|
||||
*/
|
||||
export function CoopBand({ players }: Props) {
|
||||
const [min, max] = players.split(/[-–]/);
|
||||
|
||||
// "1-6" counts up to six; a non-numeric range ("2+", "TBA") is printed as
|
||||
// written rather than guessed at.
|
||||
const maxValue = Number(max);
|
||||
const countable = Number.isFinite(maxValue) && maxValue > Number(min);
|
||||
|
||||
return (
|
||||
<section className="relative isolate overflow-hidden border-t border-border px-6 py-20 text-center sm:py-28 lg:px-10">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 -z-10 bg-[radial-gradient(55%_80%_at_50%_50%,rgba(196,184,150,0.12),transparent_70%)]"
|
||||
/>
|
||||
|
||||
<Reveal direction="scale" duration={0.8}>
|
||||
<p className="flex items-center justify-center gap-[0.06em] font-display text-[clamp(4.5rem,14vw,10rem)] leading-[0.82] tracking-wider text-accent">
|
||||
<span>{min}</span>
|
||||
{max && (
|
||||
<>
|
||||
<span className="text-[0.4em] text-foreground-muted">–</span>
|
||||
{countable ? (
|
||||
<CountUp
|
||||
value={maxValue}
|
||||
from={Number(min)}
|
||||
duration={1.1}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
) : (
|
||||
<span>{max}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<h2 className="mt-8 font-display text-2xl uppercase tracking-wider sm:text-3xl">
|
||||
Better together
|
||||
</h2>
|
||||
|
||||
<p className="mx-auto mt-4 max-w-[34ch] text-lg leading-relaxed text-foreground-muted">
|
||||
Solo works. Six works better. One world, shared by all of you.
|
||||
</p>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { StatusBadge } from "../ui/StatusBadge";
|
||||
import { Logo } from "../ui/Logo";
|
||||
import { Spotlight } from "../ui/Spotlight";
|
||||
import type { Game } from "@/lib/games";
|
||||
|
||||
type Props = {
|
||||
game: Game;
|
||||
/** Heading level, so the card fits the page outline. */
|
||||
as?: "h2" | "h3";
|
||||
};
|
||||
|
||||
/**
|
||||
* Catalogue card for /games: artwork on one side, text on a solid surface on
|
||||
* the other. The home page uses its own showcase band instead — see
|
||||
* `components/home/FeaturedGame.tsx`.
|
||||
*
|
||||
* Text is deliberately NOT overlaid on the artwork. The Emberwild key art is
|
||||
* light cream and the in-game captures are bright green — cream body text on
|
||||
* top of either one was unreadable, whatever gradient was applied.
|
||||
*/
|
||||
export function GameCard({ game, as: Tag = "h2" }: Props) {
|
||||
const artwork = game.keyArt ?? game.banner;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/games/${game.slug}`}
|
||||
className="group relative grid overflow-hidden rounded-2xl border border-border bg-surface transition duration-300 ease-out hover:-translate-y-1 hover:border-accent/60 hover:shadow-[0_24px_50px_-30px_rgba(0,0,0,0.9)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent md:grid-cols-2"
|
||||
>
|
||||
{/* Artwork panel — no text on top of it */}
|
||||
<div className="relative aspect-[16/10] overflow-hidden bg-surface-elevated md:aspect-auto md:min-h-[20rem]">
|
||||
{artwork ? (
|
||||
<Image
|
||||
src={artwork}
|
||||
alt={`${game.title} key art`}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
className="object-cover transition-transform duration-700 ease-out group-hover:scale-[1.04]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Logo width={120} className="opacity-20" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content panel — solid background, guaranteed contrast */}
|
||||
<div className="flex flex-col items-start justify-center gap-5 p-8 lg:p-10">
|
||||
<StatusBadge status={game.status} />
|
||||
|
||||
<Tag className="font-display text-3xl leading-none tracking-wider transition-colors duration-300 group-hover:text-accent sm:text-4xl">
|
||||
{game.title.toUpperCase()}
|
||||
</Tag>
|
||||
|
||||
<p className="text-base leading-relaxed text-foreground-muted">
|
||||
{game.tagline}
|
||||
</p>
|
||||
|
||||
<ul className="flex flex-wrap gap-2" aria-label="Genres">
|
||||
{game.genres.map((genre) => (
|
||||
<li
|
||||
key={genre}
|
||||
className="border border-border px-3 py-1 text-xs uppercase tracking-widest text-foreground-muted transition-colors duration-300 group-hover:border-accent/30 group-hover:text-foreground"
|
||||
>
|
||||
{genre}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<span className="mt-2 inline-flex items-center gap-2 font-display text-sm tracking-widest text-accent">
|
||||
DISCOVER {game.title.toUpperCase()}
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block transition-transform duration-300 group-hover:translate-x-1"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Spotlight />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Image from "next/image";
|
||||
import { StatusBadge } from "../ui/StatusBadge";
|
||||
import { ButtonLink } from "../ui/ButtonLink";
|
||||
import { Parallax } from "../ui/Parallax";
|
||||
import type { Game } from "@/lib/games";
|
||||
|
||||
type Props = { game: Game };
|
||||
|
||||
/**
|
||||
* Game page hero: the capture as a background, the title on top of it.
|
||||
*
|
||||
* The previous version showed the artwork in a bare band and put the title on
|
||||
* a solid block underneath, on the grounds that the in-game captures are too
|
||||
* bright to carry cream text. That is true of a raw capture — but the fix is a
|
||||
* scrim, not a separate block. A flat tint plus a bottom-up gradient give the
|
||||
* type a dark bed while the capture still reads, exactly as the home hero does
|
||||
* over the video. Without it the page opened on an image that said nothing:
|
||||
* no title, no status, no call to action above the fold.
|
||||
*/
|
||||
export function GameHero({ game }: Props) {
|
||||
const artwork = game.banner ?? game.keyArt;
|
||||
|
||||
/**
|
||||
* The spec line. Phrased so it needs no labels: "1-6 players" says what
|
||||
* "PLAYERS / 1-6" said in two words and a column. As its own band under the
|
||||
* hero this was a lonely row of text floating in a dark gap; belonging to
|
||||
* the title block, it reads as part of the pitch.
|
||||
*/
|
||||
const meta = [
|
||||
game.players && `${game.players} players`,
|
||||
game.platform,
|
||||
game.releaseWindow && `Release ${game.releaseWindow}`,
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
|
||||
return (
|
||||
<section className="relative isolate flex min-h-[70svh] items-end overflow-hidden bg-background px-6 pb-14 pt-32 sm:min-h-[78svh] sm:pb-20 lg:px-10">
|
||||
{/* The capture drifts slower than the copy on top of it. Overscanned by
|
||||
8% at each end so the drift never exposes the edge of the frame. */}
|
||||
{artwork && (
|
||||
<Parallax
|
||||
distance={70}
|
||||
range={600}
|
||||
className="absolute -top-[8%] left-0 -z-20 h-[116%] w-full"
|
||||
>
|
||||
<Image
|
||||
src={artwork}
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</Parallax>
|
||||
)}
|
||||
|
||||
{/* Scrim: dark bed under the copy, lighter over the artwork, plus a top
|
||||
band so the transparent navbar stays legible. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 -z-10 bg-background/45"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to top, #0a0a0a 3%, rgba(10,10,10,0.75) 32%, rgba(10,10,10,0.15) 70%, rgba(10,10,10,0.7) 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl">
|
||||
<StatusBadge status={game.status} />
|
||||
|
||||
<h1 className="mt-6 font-display text-[clamp(2.75rem,1.5rem+5vw,7rem)] leading-[0.92] tracking-wider">
|
||||
{game.title.toUpperCase()}
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-xl text-lg leading-relaxed text-foreground sm:text-xl">
|
||||
{game.tagline}
|
||||
</p>
|
||||
|
||||
{/* Genres carry their own non-breaking spans: a line may only break
|
||||
between them, never inside one ("CO-" / "OP" was a real thing). */}
|
||||
<div className="mt-8 flex flex-wrap items-center gap-x-3 gap-y-2 text-xs uppercase tracking-[0.2em] text-foreground-muted">
|
||||
{game.genres.map((genre, index) => (
|
||||
<span key={genre} className="flex items-center gap-3">
|
||||
{index > 0 && (
|
||||
<span aria-hidden className="text-foreground-muted/40">
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
<span className="whitespace-nowrap">{genre}</span>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{meta.length > 0 && (
|
||||
<span aria-hidden className="mx-1 h-3 w-px bg-foreground-muted/30" />
|
||||
)}
|
||||
|
||||
{meta.map((entry, index) => (
|
||||
<span key={entry} className="flex items-center gap-3">
|
||||
{index > 0 && (
|
||||
<span aria-hidden className="text-foreground-muted/40">
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
<span className="whitespace-nowrap text-foreground">{entry}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex flex-col gap-4 sm:flex-row">
|
||||
{game.steamUrl && (
|
||||
<ButtonLink href={game.steamUrl} block>
|
||||
WISHLIST ON STEAM
|
||||
</ButtonLink>
|
||||
)}
|
||||
<ButtonLink
|
||||
href="#gallery"
|
||||
variant={game.steamUrl ? "outline" : "primary"}
|
||||
block
|
||||
>
|
||||
SEE THE SCREENSHOTS
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "../ui/Reveal";
|
||||
import type { Pillar } from "@/lib/games";
|
||||
|
||||
type Props = { pillars: Pillar[] };
|
||||
|
||||
/**
|
||||
* What the player does — one band per pillar, image and copy swapping sides.
|
||||
*
|
||||
* This is the block the game page was missing: it had a status, a fact sheet
|
||||
* and a gallery, but nowhere did it say what you actually *do*. Modelled on
|
||||
* the EmberWild site's pillar bands: one word, one sentence, one capture. The
|
||||
* screenshot carries the selling, so the copy never repeats what it shows.
|
||||
*/
|
||||
export function GamePillars({ pillars }: Props) {
|
||||
if (pillars.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border-t border-border">
|
||||
{pillars.map((pillar, index) => {
|
||||
const flipped = index % 2 === 1;
|
||||
|
||||
return (
|
||||
<section
|
||||
key={pillar.title}
|
||||
className="mx-auto grid max-w-6xl items-center gap-8 px-6 py-14 sm:py-20 lg:grid-cols-2 lg:gap-16 lg:px-10"
|
||||
>
|
||||
{/* The capture enters from the side it sits on, so a run of
|
||||
pillars reads as a zig-zag rather than as four identical
|
||||
fade-ups. */}
|
||||
<Reveal
|
||||
direction={flipped ? "right" : "left"}
|
||||
className={flipped ? "lg:order-2" : undefined}
|
||||
>
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-2xl border border-border bg-surface-elevated">
|
||||
<Image
|
||||
src={pillar.shot}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 50vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal
|
||||
delay={0.1}
|
||||
className={flipped ? "lg:order-1" : undefined}
|
||||
>
|
||||
<h3 className="font-display text-[clamp(2rem,1.4rem+2.2vw,3.25rem)] uppercase leading-none tracking-wider">
|
||||
{pillar.title}
|
||||
</h3>
|
||||
<p className="mt-5 max-w-md text-lg leading-relaxed text-foreground-muted">
|
||||
{pillar.body}
|
||||
</p>
|
||||
</Reveal>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Stagger, StaggerItem } from "../ui/Stagger";
|
||||
import { EASE_OUT_EXPO } from "@/lib/motion";
|
||||
import type { Screenshot } from "@/lib/games";
|
||||
|
||||
type Props = {
|
||||
screenshots: Screenshot[];
|
||||
/** Marks the captures as work in progress. */
|
||||
wip?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Screenshot grid with a lightbox.
|
||||
*
|
||||
* Captions live under the thumbnail and on a solid bar inside the lightbox —
|
||||
* never floating over the image, which is far too bright to carry text.
|
||||
*/
|
||||
/**
|
||||
* Which way the next capture slides in. Stored rather than derived: after the
|
||||
* index has changed there is no way to tell whether it went up or wrapped
|
||||
* around from the last shot to the first.
|
||||
*/
|
||||
const slide = {
|
||||
enter: (direction: number) => ({ opacity: 0, x: direction * 48 }),
|
||||
center: { opacity: 1, x: 0 },
|
||||
exit: (direction: number) => ({ opacity: 0, x: direction * -48 }),
|
||||
};
|
||||
|
||||
export function ScreenshotGallery({ screenshots, wip = true }: Props) {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
const [direction, setDirection] = useState(1);
|
||||
const triggersRef = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpenIndex((current) => {
|
||||
if (current !== null) triggersRef.current[current]?.focus();
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const step = useCallback(
|
||||
(delta: number) => {
|
||||
setDirection(delta);
|
||||
setOpenIndex((current) =>
|
||||
current === null
|
||||
? current
|
||||
: (current + delta + screenshots.length) % screenshots.length,
|
||||
);
|
||||
},
|
||||
[screenshots.length],
|
||||
);
|
||||
|
||||
// Keyboard controls + scroll lock while the lightbox is open.
|
||||
useEffect(() => {
|
||||
if (openIndex === null) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") close();
|
||||
else if (event.key === "ArrowRight") step(1);
|
||||
else if (event.key === "ArrowLeft") step(-1);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
dialogRef.current?.focus();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [openIndex, close, step]);
|
||||
|
||||
if (screenshots.length === 0) return null;
|
||||
|
||||
const active = openIndex === null ? null : screenshots[openIndex];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stagger as="ul" step={0.06} className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{screenshots.map((shot, index) => (
|
||||
<StaggerItem as="li" key={shot.src}>
|
||||
<button
|
||||
type="button"
|
||||
ref={(node) => {
|
||||
triggersRef.current[index] = node;
|
||||
}}
|
||||
onClick={() => {
|
||||
setDirection(1);
|
||||
setOpenIndex(index);
|
||||
}}
|
||||
className="group block w-full text-left transition-transform duration-200 ease-out active:scale-[0.98] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
<span className="relative block aspect-video overflow-hidden rounded-2xl border border-border bg-surface-elevated transition-colors duration-300 group-hover:border-accent/60">
|
||||
<Image
|
||||
src={shot.src}
|
||||
alt={shot.caption}
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
className="object-cover transition-transform duration-700 ease-out group-hover:scale-[1.04]"
|
||||
/>
|
||||
{wip && (
|
||||
<span className="absolute left-3 top-3 bg-background/90 px-2.5 py-1 font-display text-[0.65rem] tracking-[0.2em] text-accent">
|
||||
WIP
|
||||
</span>
|
||||
)}
|
||||
{/* The label rises into place rather than appearing, so the
|
||||
overlay reads as one gesture instead of a flash. */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 flex items-center justify-center bg-background/50 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
>
|
||||
<span className="translate-y-2 border border-accent px-4 py-2 font-display text-xs tracking-[0.25em] text-accent transition-transform duration-300 ease-out group-hover:translate-y-0">
|
||||
VIEW
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-3 block text-sm text-foreground-muted transition-colors duration-300 group-hover:text-foreground">
|
||||
{shot.caption}
|
||||
</span>
|
||||
</button>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</Stagger>
|
||||
|
||||
<AnimatePresence>
|
||||
{active && (
|
||||
<motion.div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={active.caption}
|
||||
tabIndex={-1}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={close}
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 bg-background/95 p-4 backdrop-blur-sm sm:p-8"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.98, y: 4 }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
|
||||
className="relative w-full max-w-6xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-t-2xl border border-border bg-surface-elevated">
|
||||
{/* Each capture slides in from the side you asked for. Without
|
||||
it, stepping through a gallery of similar screenshots gives
|
||||
no sense of having moved at all. */}
|
||||
<AnimatePresence initial={false} custom={direction}>
|
||||
<motion.div
|
||||
key={active.src}
|
||||
custom={direction}
|
||||
variants={slide}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<Image
|
||||
src={active.src}
|
||||
alt={active.caption}
|
||||
fill
|
||||
sizes="100vw"
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 rounded-b-2xl border border-t-0 border-border bg-surface px-4 py-3">
|
||||
<p className="text-sm text-foreground-muted">
|
||||
{active.caption}
|
||||
</p>
|
||||
<p className="shrink-0 font-display text-xs tracking-[0.25em] text-foreground-muted">
|
||||
{(openIndex ?? 0) + 1} / {screenshots.length}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{screenshots.length > 1 && (
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
aria-label="Previous screenshot"
|
||||
className="group border border-border px-5 py-2 font-display text-sm tracking-widest text-foreground transition duration-200 hover:border-accent hover:text-accent active:scale-95"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block transition-transform duration-300 ease-out group-hover:-translate-x-1"
|
||||
>
|
||||
←
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(1)}
|
||||
aria-label="Next screenshot"
|
||||
className="group border border-border px-5 py-2 font-display text-sm tracking-widest text-foreground transition duration-200 hover:border-accent hover:text-accent active:scale-95"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label="Close gallery"
|
||||
className="absolute right-4 top-4 border border-border bg-background/80 px-4 py-2 font-display text-xs tracking-[0.25em] text-foreground transition duration-200 hover:border-accent hover:text-accent active:scale-95 sm:right-8 sm:top-8"
|
||||
>
|
||||
CLOSE ✕
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Section } from "../ui/Section";
|
||||
import { SectionHeading } from "../ui/SectionHeading";
|
||||
import { ButtonLink } from "../ui/ButtonLink";
|
||||
import { Reveal } from "../ui/Reveal";
|
||||
import { Newsletter } from "../forms/Newsletter";
|
||||
import { PostFeature } from "../devblog/PostFeature";
|
||||
import type { DevblogPost } from "@/lib/devblog";
|
||||
|
||||
type Props = {
|
||||
/** The single most recent post, or null while the devblog is empty. */
|
||||
latest: DevblogPost | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The last band before the footer, and the only conversion point on the page.
|
||||
*
|
||||
* It replaces the old "follow the journey" block, which framed a full section
|
||||
* — eyebrow, display title, lead, button — around a list that currently holds
|
||||
* one entry, and so read as an empty section. Here the latest post is a single
|
||||
* deliberate panel next to the newsletter form: it looks intentional with one
|
||||
* post, and stays correct once there are twenty.
|
||||
*/
|
||||
export function Dispatch({ latest }: Props) {
|
||||
return (
|
||||
<div className="relative isolate overflow-hidden border-t border-border">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-x-0 top-0 -z-10 h-[60%] bg-[radial-gradient(70%_100%_at_50%_0%,rgba(196,184,150,0.09),transparent_70%)]"
|
||||
/>
|
||||
|
||||
<Section>
|
||||
<Reveal>
|
||||
<SectionHeading
|
||||
eyebrow="DEVBLOG"
|
||||
title="BUILT IN THE OPEN"
|
||||
lead="Design decisions, milestones and the occasional mistake, written down as they happen."
|
||||
action={
|
||||
<ButtonLink href="/devblog" variant="outline">
|
||||
ALL POSTS
|
||||
</ButtonLink>
|
||||
}
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1}>
|
||||
<div className="mt-12 grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
{latest && <PostFeature post={latest} />}
|
||||
|
||||
<div
|
||||
className={`flex flex-col justify-center rounded-2xl border border-border bg-surface p-8 lg:p-10 ${
|
||||
latest ? "" : "lg:col-span-2"
|
||||
}`}
|
||||
>
|
||||
<h3 className="font-display text-2xl tracking-wider sm:text-3xl">
|
||||
GET IT BY EMAIL
|
||||
</h3>
|
||||
<p className="mt-4 leading-relaxed text-foreground-muted">
|
||||
One mail per major update. No launch countdowns, no noise, just
|
||||
the build as it happens.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<Newsletter variant="compact" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Image from "next/image";
|
||||
import { Section } from "../ui/Section";
|
||||
import { ButtonLink } from "../ui/ButtonLink";
|
||||
import { StatusBadge } from "../ui/StatusBadge";
|
||||
import { Reveal } from "../ui/Reveal";
|
||||
import type { Game } from "@/lib/games";
|
||||
|
||||
type Props = { game: Game };
|
||||
|
||||
/**
|
||||
* The home page showcase for the studio's current game.
|
||||
*
|
||||
* Deliberately NOT the `GameCard` used on /games. A card is a catalogue
|
||||
* object — it is right when a visitor is comparing several games, and wrong
|
||||
* when there is one game and the whole page exists to sell it.
|
||||
*
|
||||
* The card also forced the key art into whatever height the text column
|
||||
* happened to be, cropping roughly a third of a 16:9 composition. Here the
|
||||
* ratio is locked, so the art is shown as it was framed.
|
||||
*/
|
||||
export function FeaturedGame({ game }: Props) {
|
||||
const artwork = game.keyArt ?? game.banner;
|
||||
|
||||
return (
|
||||
<Section>
|
||||
{/* The artwork gets the larger column: it is the only thing on the home
|
||||
page that shows the game itself. */}
|
||||
<div className="grid items-center gap-10 lg:grid-cols-[0.8fr_1.2fr] lg:gap-12">
|
||||
{/* Artwork first in the DOM so it also leads on mobile, where the
|
||||
layout collapses to a single column. */}
|
||||
<Reveal className="lg:order-2" direction="right">
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-2xl border border-border bg-surface-elevated">
|
||||
{artwork && (
|
||||
<Image
|
||||
src={artwork}
|
||||
alt={`${game.title} key art`}
|
||||
fill
|
||||
sizes="(max-width: 1024px) 100vw, 60vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="lg:order-1">
|
||||
<Reveal>
|
||||
<p className="mb-4 font-display text-xs tracking-[0.3em] text-accent sm:text-sm">
|
||||
OUR GAMES
|
||||
</p>
|
||||
|
||||
<h2 className="font-display text-[clamp(2.5rem,1.5rem+3vw,4.5rem)] leading-[0.95] tracking-wider">
|
||||
{game.title.toUpperCase()}
|
||||
</h2>
|
||||
|
||||
<p className="mt-6 max-w-lg text-lg leading-relaxed text-foreground-muted">
|
||||
{game.tagline}
|
||||
</p>
|
||||
|
||||
{/* Genres as one quiet line instead of a row of chips — the badge
|
||||
already carries the only label that needs a border. */}
|
||||
<div className="mt-8 flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<StatusBadge status={game.status} />
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-foreground-muted">
|
||||
{game.genres.join(" · ")}
|
||||
{game.releaseWindow && ` · ${game.releaseWindow}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex flex-col gap-4 sm:flex-row">
|
||||
<ButtonLink href={`/games/${game.slug}`} block>
|
||||
DISCOVER {game.title.toUpperCase()}
|
||||
</ButtonLink>
|
||||
{game.steamUrl && (
|
||||
<ButtonLink href={game.steamUrl} variant="outline" block>
|
||||
WISHLIST ON STEAM
|
||||
</ButtonLink>
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
motion,
|
||||
useReducedMotion,
|
||||
useScroll,
|
||||
useTransform,
|
||||
} from "framer-motion";
|
||||
import { Logo } from "../ui/Logo";
|
||||
import { ButtonLink } from "../ui/ButtonLink";
|
||||
import { HERO_VIDEO, STUDIO } from "@/lib/site";
|
||||
import { EASE_OUT_EXPO } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* Full-height hero.
|
||||
*
|
||||
* The dark plate sits under the video, so the headline is readable from the
|
||||
* first paint — before the background has loaded, and for visitors who have
|
||||
* reduced motion enabled (they get no video at all).
|
||||
*
|
||||
* Layout note: everything is in normal flow — the scroll hint is NOT absolutely
|
||||
* positioned. On a laptop (wide but only ~900px tall) an absolute hint sat on
|
||||
* top of the buttons. Spacing and type are clamped against `vh` so the block
|
||||
* shrinks with the viewport height instead of overflowing it.
|
||||
*/
|
||||
export function HomeHero() {
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
// Skip entrance animations entirely when reduced motion is requested.
|
||||
const fade = (delay: number) =>
|
||||
reduced
|
||||
? {}
|
||||
: {
|
||||
initial: { opacity: 0, y: 24 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
transition: { duration: 0.8, delay, ease: "easeOut" as const },
|
||||
};
|
||||
|
||||
/**
|
||||
* Scroll response. The video drifts slower than the page and the copy
|
||||
* dissolves before the section ends, so the hero hands over to the next band
|
||||
* instead of sliding out of frame intact. Page scroll is read directly —
|
||||
* the hero is always at offset 0, so there is nothing to measure.
|
||||
*/
|
||||
const { scrollY } = useScroll();
|
||||
const videoY = useTransform(scrollY, [0, 800], [0, 150], { clamp: true });
|
||||
const contentY = useTransform(scrollY, [0, 600], [0, 70], { clamp: true });
|
||||
// Holds at full strength for the first 100px: a visitor who nudges the wheel
|
||||
// to see whether the page moves should not find the headline already faded.
|
||||
const contentOpacity = useTransform(scrollY, [100, 560], [1, 0], {
|
||||
clamp: true,
|
||||
});
|
||||
const hintOpacity = useTransform(scrollY, [0, 180], [1, 0], { clamp: true });
|
||||
|
||||
return (
|
||||
<section className="relative isolate flex min-h-[100svh] flex-col overflow-hidden bg-background px-6 pt-[max(6rem,10vh)] pb-[clamp(1.5rem,4vh,2.5rem)] lg:px-10">
|
||||
{!reduced && (
|
||||
// Taller than the section and pulled up, so the parallax never
|
||||
// uncovers the bottom edge of the frame.
|
||||
<motion.video
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={HERO_VIDEO.poster ?? undefined}
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
style={{ y: videoY }}
|
||||
className="absolute -top-[10%] left-0 z-0 h-[120%] w-full object-cover"
|
||||
>
|
||||
<source src={HERO_VIDEO.src} type="video/mp4" />
|
||||
</motion.video>
|
||||
)}
|
||||
|
||||
{/* Readability scrim: flat tint + vignette, in a single layer */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 z-10 bg-background/65"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(ellipse at 50% 45%, rgba(10,10,10,0) 25%, rgba(10,10,10,0.9) 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
style={reduced ? undefined : { y: contentY, opacity: contentOpacity }}
|
||||
className="relative z-20 mx-auto flex w-full max-w-4xl flex-1 flex-col items-center justify-center text-center"
|
||||
>
|
||||
<motion.div
|
||||
{...(reduced
|
||||
? {}
|
||||
: {
|
||||
initial: { opacity: 0, scale: 0.94 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
transition: { duration: 1, ease: EASE_OUT_EXPO },
|
||||
})}
|
||||
className="mb-[clamp(1.5rem,4vh,2.5rem)]"
|
||||
>
|
||||
<Logo
|
||||
width={200}
|
||||
priority
|
||||
className="h-auto w-[clamp(110px,13vh,200px)] drop-shadow-[0_0_40px_rgba(196,184,150,0.2)]"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.h1
|
||||
{...fade(0.25)}
|
||||
className="font-display text-[min(3rem,12vh)] leading-[0.95] tracking-wider text-balance sm:text-[min(4.5rem,12vh)] lg:text-[min(6rem,12vh)]"
|
||||
>
|
||||
HIGHLAND GAMES
|
||||
<br />
|
||||
<span className="text-accent">STUDIO</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
{...fade(0.45)}
|
||||
className="mt-[clamp(1.25rem,3vh,2rem)] max-w-xl text-lg tracking-wide text-foreground-muted text-balance sm:text-xl"
|
||||
>
|
||||
{STUDIO.tagline}
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
{...fade(0.65)}
|
||||
className="mt-[clamp(1.75rem,4vh,3rem)] flex w-full flex-col items-center gap-4 sm:w-auto sm:flex-row"
|
||||
>
|
||||
<ButtonLink href="/games" block>
|
||||
EXPLORE OUR GAMES
|
||||
</ButtonLink>
|
||||
<ButtonLink href="/studio" variant="outline" block>
|
||||
MEET THE STUDIO
|
||||
</ButtonLink>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{!reduced && (
|
||||
// Fades on the first flick of the wheel: an invitation to scroll that
|
||||
// is still there once you have is just decoration. Two layers, because
|
||||
// one element cannot hold both a load-time `animate` opacity and a
|
||||
// scroll-driven one.
|
||||
<motion.div
|
||||
style={{ opacity: hintOpacity }}
|
||||
aria-hidden
|
||||
className="relative z-20 mt-[clamp(1.5rem,3vh,2.5rem)] flex shrink-0 flex-col items-center self-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.4, duration: 0.8 }}
|
||||
className="flex flex-col items-center gap-2 text-xs tracking-widest text-foreground-muted"
|
||||
>
|
||||
<span className="font-display">SCROLL</span>
|
||||
<motion.span
|
||||
animate={{ y: [0, 8, 0] }}
|
||||
transition={{ duration: 1.8, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="block h-[clamp(1.25rem,3vh,2rem)] w-px bg-foreground-muted"
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Stagger, StaggerItem } from "../ui/Stagger";
|
||||
|
||||
/**
|
||||
* One sentence, full width, no eyebrow, no button.
|
||||
*
|
||||
* The tonal break between the hero and the game showcase: the page stops
|
||||
* selling for a moment and simply says what the studio is for. The type is
|
||||
* fluid rather than stepped, and the band carries its own gradient so the
|
||||
* page is not one uninterrupted flat black from hero to footer.
|
||||
*
|
||||
* The two lines arrive separately, a beat apart. It is the only piece of copy
|
||||
* on the site meant to be read as speech, and the pause between them is the
|
||||
* point — the second line answers the first.
|
||||
*/
|
||||
export function Statement() {
|
||||
return (
|
||||
<section className="bg-[linear-gradient(180deg,#0a0a0a_0%,#151515_50%,#0a0a0a_100%)] px-6 py-24 sm:py-36 lg:px-10">
|
||||
<Stagger step={0.22} className="mx-auto max-w-6xl">
|
||||
<p className="font-display text-[clamp(1.75rem,1rem+3.2vw,4rem)] leading-[1.12] tracking-wider text-balance text-foreground-muted">
|
||||
<StaggerItem as="span" className="block">
|
||||
A game you finish is a game you leave behind.
|
||||
</StaggerItem>
|
||||
<StaggerItem as="span" className="block text-foreground">
|
||||
We build worlds worth staying in.
|
||||
</StaggerItem>
|
||||
</p>
|
||||
</Stagger>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Section } from "../ui/Section";
|
||||
import { SectionHeading } from "../ui/SectionHeading";
|
||||
import { ButtonLink } from "../ui/ButtonLink";
|
||||
import { Reveal } from "../ui/Reveal";
|
||||
import { Stagger, StaggerItem } from "../ui/Stagger";
|
||||
import { Logo } from "../ui/Logo";
|
||||
import { CountUp } from "../ui/CountUp";
|
||||
import { STUDIO } from "@/lib/site";
|
||||
import { games } from "@/lib/games";
|
||||
import { TEAM } from "@/lib/studio";
|
||||
|
||||
/**
|
||||
* Who is behind the game.
|
||||
*
|
||||
* Two columns rather than another full-width text block: the copy on the left,
|
||||
* a brand plate on the right. The figures are counted from the real data, so
|
||||
* the home page cannot claim a team size or a catalogue the rest of the site
|
||||
* contradicts.
|
||||
*/
|
||||
export function StudioIntro() {
|
||||
const foundingYear = Number(STUDIO.foundingDate);
|
||||
|
||||
const facts = [
|
||||
{ value: TEAM.length, from: 0, label: "People" },
|
||||
{
|
||||
value: games.length,
|
||||
from: 0,
|
||||
label: games.length > 1 ? "Worlds in progress" : "World in progress",
|
||||
},
|
||||
// A year counting up from zero reads as a random number for most of the
|
||||
// animation. Rolling the last dozen keeps it legible as a date throughout.
|
||||
{ value: foundingYear, from: foundingYear - 12, label: "Founded" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Section divider>
|
||||
<div className="grid items-center gap-12 lg:grid-cols-[1.1fr_0.9fr] lg:gap-20">
|
||||
<Reveal>
|
||||
<SectionHeading
|
||||
eyebrow="THE STUDIO"
|
||||
title={
|
||||
<>
|
||||
A SMALL TEAM,
|
||||
<br />
|
||||
BIG HORIZONS.
|
||||
</>
|
||||
}
|
||||
lead="We build worlds where every horizon hides a story. Hand-crafted games made to be lived in, not just played through. Each one a different summit, a different climb."
|
||||
/>
|
||||
|
||||
{/* The three figures land one after the other, each one starting to
|
||||
count as it arrives — three counters running at once is a slot
|
||||
machine. */}
|
||||
<Stagger
|
||||
as="ul"
|
||||
step={0.12}
|
||||
className="mt-10 grid grid-cols-3 gap-6 border-t border-border pt-8"
|
||||
>
|
||||
{facts.map((fact) => (
|
||||
<StaggerItem as="li" key={fact.label}>
|
||||
<p className="font-display text-3xl tracking-wider text-accent sm:text-4xl">
|
||||
<CountUp value={fact.value} from={fact.from} />
|
||||
</p>
|
||||
<p className="mt-2 text-[0.65rem] uppercase tracking-[0.2em] text-foreground-muted">
|
||||
{fact.label}
|
||||
</p>
|
||||
</StaggerItem>
|
||||
))}
|
||||
</Stagger>
|
||||
|
||||
<div className="mt-10">
|
||||
<ButtonLink href="/studio" variant="outline">
|
||||
MEET THE TEAM
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Brand plate. Deliberately not a team photo: only one member has been
|
||||
shot so far, and a single face cannot stand in for the studio. */}
|
||||
<Reveal delay={0.1} direction="right">
|
||||
<div className="group relative isolate flex aspect-[4/3] items-center justify-center overflow-hidden rounded-2xl border border-border bg-surface transition-colors duration-500 hover:border-accent/40 lg:aspect-[4/5]">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 -z-10 bg-[radial-gradient(60%_60%_at_50%_45%,rgba(196,184,150,0.14),transparent_70%)]"
|
||||
/>
|
||||
<Logo
|
||||
variant="wordmark"
|
||||
width={320}
|
||||
alt={STUDIO.name}
|
||||
className="h-auto w-[min(72%,320px)] transition-transform duration-700 ease-out group-hover:scale-[1.03]"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { SocialLinks } from "./SocialLinks";
|
||||
import {
|
||||
BRAND,
|
||||
CONTACT_CHANNELS,
|
||||
LEGAL_LINKS,
|
||||
NAV_LINKS,
|
||||
STUDIO,
|
||||
} from "@/lib/site";
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="mt-auto border-t border-border">
|
||||
<div className="mx-auto grid max-w-7xl gap-12 px-6 py-16 md:grid-cols-4 lg:px-10">
|
||||
<div className="md:col-span-2">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={`${STUDIO.name} home`}
|
||||
className="group inline-block"
|
||||
>
|
||||
<Image
|
||||
src={BRAND.wordmark.src}
|
||||
alt={STUDIO.name}
|
||||
width={220}
|
||||
height={Math.round(
|
||||
(220 * BRAND.wordmark.height) / BRAND.wordmark.width,
|
||||
)}
|
||||
className="transition duration-300 ease-out group-hover:-translate-y-0.5 group-hover:opacity-80"
|
||||
/>
|
||||
</Link>
|
||||
<p className="mt-6 max-w-sm text-sm leading-relaxed text-foreground-muted">
|
||||
{STUDIO.tagline} An indie studio crafting survival adventures.
|
||||
</p>
|
||||
<a
|
||||
href={`mailto:${CONTACT_CHANNELS[0].email}`}
|
||||
className="underline-grow mt-4 inline-block break-all text-sm text-foreground-muted transition-colors hover:text-accent"
|
||||
>
|
||||
{CONTACT_CHANNELS[0].email}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Footer">
|
||||
<h2 className="mb-5 font-display text-sm tracking-[0.25em] text-foreground-muted">
|
||||
EXPLORE
|
||||
</h2>
|
||||
<ul className="space-y-3 text-sm">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="group relative inline-flex items-center gap-1.5 text-foreground-muted transition-colors hover:text-foreground"
|
||||
>
|
||||
{link.label}
|
||||
<span
|
||||
aria-hidden
|
||||
className="-ml-1 text-xs opacity-0 transition-all duration-300 ease-out group-hover:ml-0 group-hover:opacity-100"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute -bottom-0.5 left-0 h-px w-0 bg-accent transition-all duration-300 ease-out group-hover:w-full"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-5 font-display text-sm tracking-[0.25em] text-foreground-muted">
|
||||
FOLLOW
|
||||
</h2>
|
||||
<SocialLinks />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 px-6 py-6 text-xs text-foreground-muted sm:flex-row lg:px-10">
|
||||
<p>
|
||||
© {new Date().getFullYear()} {STUDIO.name}. All rights reserved.
|
||||
</p>
|
||||
<ul className="flex items-center gap-6">
|
||||
{LEGAL_LINKS.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="underline-grow inline-block transition-colors hover:text-accent"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="hidden font-display tracking-[0.25em] lg:block">
|
||||
WHERE SUMMITS BECOME WORLDS
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Logo } from "../ui/Logo";
|
||||
import { ScrollProgress } from "../ui/ScrollProgress";
|
||||
import { NAV_LINKS, STUDIO } from "@/lib/site";
|
||||
import { EASE_OUT_EXPO } from "@/lib/motion";
|
||||
|
||||
export function Navbar() {
|
||||
const pathname = usePathname();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
|
||||
// The drawer stores the route it was opened on, so any navigation closes it
|
||||
// by derivation — no effect syncing state to the router.
|
||||
const [openedOn, setOpenedOn] = useState<string | null>(null);
|
||||
const open = openedOn === pathname;
|
||||
const close = () => setOpenedOn(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 20);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
// Lock the page and allow Escape to dismiss while the drawer is open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpenedOn(null);
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const isActive = (href: string) =>
|
||||
pathname === href || pathname.startsWith(`${href}/`);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`fixed inset-x-0 top-0 z-50 transition-colors duration-300 ${
|
||||
scrolled || open
|
||||
? "border-b border-border bg-background/85 backdrop-blur-md"
|
||||
: "border-b border-transparent bg-transparent"
|
||||
}`}
|
||||
>
|
||||
<nav className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6 lg:h-20 lg:px-10">
|
||||
<Link
|
||||
href="/"
|
||||
aria-label={`${STUDIO.name} home`}
|
||||
className="group flex items-center gap-3"
|
||||
>
|
||||
<Logo
|
||||
width={44}
|
||||
priority
|
||||
className="transition-transform duration-300 ease-out group-hover:-translate-y-0.5 group-hover:scale-105"
|
||||
/>
|
||||
<span className="hidden font-display text-xl tracking-wider transition-colors group-hover:text-accent sm:block">
|
||||
{STUDIO.shortName}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<ul className="hidden items-center gap-1 md:flex">
|
||||
{NAV_LINKS.map((link) => {
|
||||
const active = isActive(link.href);
|
||||
|
||||
return (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`group/nav relative px-3 py-2 text-sm tracking-wide transition-colors ${
|
||||
active
|
||||
? "text-accent"
|
||||
: "text-foreground-muted hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
|
||||
{/* Hover hairline for the pages you are not on. Sits under
|
||||
the active marker, so the two never stack visibly. */}
|
||||
{!active && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-x-3 -bottom-0.5 h-px origin-left scale-x-0 bg-foreground-muted/60 transition-transform duration-300 ease-out group-hover/nav:scale-x-100"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* One shared element for every link: Framer moves it from
|
||||
the old tab to the new one instead of cross-fading two
|
||||
separate underlines. */}
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId="nav-active"
|
||||
aria-hidden
|
||||
transition={{ type: "spring", stiffness: 420, damping: 34 }}
|
||||
className="absolute inset-x-3 -bottom-0.5 h-px bg-accent"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label={open ? "Close menu" : "Open menu"}
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-menu"
|
||||
onClick={() => setOpenedOn(open ? null : pathname)}
|
||||
className="relative flex h-10 w-10 items-center justify-center text-foreground transition-transform duration-200 hover:text-accent active:scale-90 md:hidden"
|
||||
>
|
||||
<span className="relative flex h-4 w-6 flex-col justify-between">
|
||||
<motion.span
|
||||
animate={open ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="block h-px origin-center bg-current"
|
||||
/>
|
||||
<motion.span
|
||||
animate={{ opacity: open ? 0 : 1 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="block h-px bg-current"
|
||||
/>
|
||||
<motion.span
|
||||
animate={open ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="block h-px origin-center bg-current"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
id="mobile-menu"
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.25, ease: EASE_OUT_EXPO }}
|
||||
className="border-t border-border bg-background/95 backdrop-blur-md md:hidden"
|
||||
>
|
||||
{/* The rows deal out one by one. The drawer is the only place on
|
||||
the site where a whole navigation appears at once, and a block
|
||||
of four identical lines arriving together reads as a flash. */}
|
||||
<motion.ul
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
visible: { transition: { staggerChildren: 0.06, delayChildren: 0.05 } },
|
||||
}}
|
||||
className="flex flex-col px-6 py-4"
|
||||
>
|
||||
{NAV_LINKS.map((link) => (
|
||||
<motion.li
|
||||
key={link.href}
|
||||
variants={{
|
||||
hidden: { opacity: 0, x: -12 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.3, ease: EASE_OUT_EXPO },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href={link.href}
|
||||
onClick={close}
|
||||
aria-current={isActive(link.href) ? "page" : undefined}
|
||||
className={`group flex items-center justify-between border-b border-border py-4 font-display text-2xl tracking-wider transition-colors last:border-b-0 ${
|
||||
isActive(link.href)
|
||||
? "text-accent"
|
||||
: "text-foreground hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
{link.label.toUpperCase()}
|
||||
<span
|
||||
aria-hidden
|
||||
className="text-base opacity-0 transition-all duration-300 ease-out group-hover:translate-x-1 group-hover:opacity-100"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</Link>
|
||||
</motion.li>
|
||||
))}
|
||||
</motion.ul>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<ScrollProgress />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { SOCIAL_LINKS } from "@/lib/site";
|
||||
|
||||
const icons: Record<string, ReactNode> = {
|
||||
x: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
),
|
||||
discord: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 0 0-4.8851-1.5152.0741.0741 0 0 0-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 0 0-.0785-.037 19.7363 19.7363 0 0 0-4.8852 1.515.0699.0699 0 0 0-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 0 0 .0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 0 0 .0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 0 0-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 0 1-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 0 1 .0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 0 1 .0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 0 1-.0066.1276 12.2986 12.2986 0 0 1-1.873.8914.0766.0766 0 0 0-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 0 0 .0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 0 0 .0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 0 0-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1569 2.4189Zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
),
|
||||
youtube: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M23.498 6.186a3.02 3.02 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.02 3.02 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.02 3.02 0 0 0 2.122 2.136C4.495 20.455 12 20.455 12 20.455s7.505 0 9.377-.505a3.02 3.02 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
|
||||
</svg>
|
||||
),
|
||||
steam: (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.456-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Every channel is listed, whether or not the account exists yet: the studio
|
||||
* wants visitors to know where it will be. Accounts without a URL render as
|
||||
* plain text marked "soon" rather than as a link to `#`, which would look
|
||||
* functional and go nowhere. Fill in `href` in `lib/site.ts` and the entry
|
||||
* becomes a real link with no change here.
|
||||
*/
|
||||
export function SocialLinks() {
|
||||
return (
|
||||
<ul className="space-y-3 text-sm">
|
||||
{SOCIAL_LINKS.map((social) => {
|
||||
const icon = (
|
||||
<span className="block h-4 w-4 shrink-0 transition duration-300 ease-out group-hover:scale-110 group-hover:-rotate-6">
|
||||
{icons[social.key]}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={social.key}>
|
||||
{social.href ? (
|
||||
<a
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group inline-flex items-center gap-3 text-foreground-muted transition-all duration-300 ease-out hover:translate-x-1 hover:text-foreground [&>span:first-child]:hover:text-accent"
|
||||
>
|
||||
{icon}
|
||||
{social.label}
|
||||
</a>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-3 text-foreground-muted/55">
|
||||
{icon}
|
||||
{social.label}
|
||||
<span className="text-[0.6rem] uppercase tracking-[0.2em]">
|
||||
Soon
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
import { EASE_OUT_EXPO } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* Appears once the visitor is far enough down that the navbar is a long way
|
||||
* back — a game page is five full bands tall, and the only way up was the
|
||||
* scrollbar.
|
||||
*
|
||||
* Threshold is in viewport heights rather than pixels, so it does not pop up
|
||||
* on the second scroll tick of a tall desktop screen.
|
||||
*/
|
||||
export function BackToTop() {
|
||||
const reduced = useReducedMotion();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setVisible(window.scrollY > window.innerHeight * 1.5);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.button
|
||||
type="button"
|
||||
aria-label="Back to top"
|
||||
onClick={() =>
|
||||
window.scrollTo({ top: 0, behavior: reduced ? "auto" : "smooth" })
|
||||
}
|
||||
initial={{ opacity: 0, scale: 0.8, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.8, y: 12 }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
|
||||
whileHover={{ y: -3 }}
|
||||
whileTap={{ scale: 0.92 }}
|
||||
className="group fixed bottom-6 right-6 z-40 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-background/80 text-foreground-muted backdrop-blur-md transition-colors hover:border-accent hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent lg:bottom-10 lg:right-10"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="text-lg leading-none transition-transform duration-300 group-hover:-translate-y-0.5"
|
||||
>
|
||||
↑
|
||||
</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Variant = "primary" | "outline" | "quiet";
|
||||
|
||||
type Props = {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
variant?: Variant;
|
||||
/** Stretch to full width on mobile — avoids cramped side-by-side buttons. */
|
||||
block?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* `active:scale` is on the base so every variant answers a click, including
|
||||
* the text-only one. The lift is small on purpose: a button that jumps reads
|
||||
* as a card, and these sit in rows of two.
|
||||
*/
|
||||
const base =
|
||||
"group relative inline-flex items-center justify-center gap-2 font-display tracking-widest text-sm transition duration-200 ease-out active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
primary:
|
||||
"sheen overflow-hidden px-8 py-3 bg-accent text-background hover:bg-foreground hover:-translate-y-0.5 hover:shadow-[0_14px_30px_-14px_rgba(196,184,150,0.6)]",
|
||||
outline:
|
||||
"px-8 py-3 border border-border text-foreground hover:border-accent hover:text-accent hover:-translate-y-0.5 hover:bg-accent/[0.06]",
|
||||
quiet: "underline-grow text-accent hover:text-foreground",
|
||||
};
|
||||
|
||||
/**
|
||||
* Every call-to-action on the site. Automatically renders an `<a>` with the
|
||||
* right rel attributes for external URLs, and a Next `<Link>` otherwise.
|
||||
*/
|
||||
export function ButtonLink({
|
||||
href,
|
||||
children,
|
||||
variant = "primary",
|
||||
block = false,
|
||||
className = "",
|
||||
}: Props) {
|
||||
const classes = `${base} ${variants[variant]} ${
|
||||
block ? "w-full sm:w-auto" : ""
|
||||
} ${className}`;
|
||||
|
||||
const isExternal = /^https?:\/\//.test(href) || href.startsWith("mailto:");
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={href.startsWith("mailto:") ? undefined : "_blank"}
|
||||
rel="noopener noreferrer"
|
||||
className={classes}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={classes}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
/**
|
||||
* The tick that confirms a message went out. Drawn rather than faded in: a
|
||||
* stroke that completes is the one moment on the site worth animating for its
|
||||
* own sake, and it gives the success panel something to arrive on.
|
||||
*/
|
||||
export function CheckMark({ className = "" }: { className?: string }) {
|
||||
const reduced = useReducedMotion();
|
||||
const draw = (delay: number) =>
|
||||
reduced
|
||||
? {}
|
||||
: {
|
||||
initial: { pathLength: 0, opacity: 0 },
|
||||
animate: { pathLength: 1, opacity: 1 },
|
||||
transition: { duration: 0.5, delay, ease: "easeOut" as const },
|
||||
};
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 52 52" fill="none" aria-hidden className={className}>
|
||||
<motion.circle
|
||||
cx="26"
|
||||
cy="26"
|
||||
r="24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-accent/35"
|
||||
{...draw(0)}
|
||||
/>
|
||||
<motion.path
|
||||
d="M15.5 26.5 L23 34 L36.5 19"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-accent"
|
||||
{...draw(0.35)}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { animate, useInView, useReducedMotion } from "framer-motion";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
/** The number to land on. */
|
||||
value: number;
|
||||
/** Where the count starts. A year should roll a few digits, not run up
|
||||
* from zero, so the caller sets its own starting point. */
|
||||
from?: number;
|
||||
/** Seconds. */
|
||||
duration?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Counts up to a number once, the first time it scrolls into view.
|
||||
*
|
||||
* The animated digits are hidden from assistive tech and the real value is
|
||||
* exposed alongside them, so a screen reader announces "3" rather than every
|
||||
* intermediate frame. Honours `prefers-reduced-motion`: the final value is
|
||||
* rendered immediately.
|
||||
*/
|
||||
export function CountUp({
|
||||
value,
|
||||
from = 0,
|
||||
duration = 1.4,
|
||||
className = "",
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const inView = useInView(ref, { once: true, margin: "-80px" });
|
||||
const reduced = useReducedMotion();
|
||||
const [count, setCount] = useState(from);
|
||||
|
||||
useEffect(() => {
|
||||
if (reduced || !inView) return;
|
||||
|
||||
const controls = animate(from, value, {
|
||||
duration,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
onUpdate: (latest) => setCount(Math.round(latest)),
|
||||
});
|
||||
|
||||
return () => controls.stop();
|
||||
}, [inView, reduced, from, value, duration]);
|
||||
|
||||
// Derived rather than stored: with reduced motion there is no animation to
|
||||
// run, so the final value is simply what renders.
|
||||
const display = reduced ? value : count;
|
||||
|
||||
return (
|
||||
<span ref={ref} className={`tabular-nums ${className}`}>
|
||||
<span aria-hidden>{display}</span>
|
||||
<span className="sr-only">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Image from "next/image";
|
||||
import { BRAND } from "@/lib/site";
|
||||
|
||||
type Props = {
|
||||
/** `mark` = mountain only, `wordmark` = mountain + studio name. */
|
||||
variant?: "mark" | "wordmark";
|
||||
/** Rendered width in px. Height is derived from the file's real ratio. */
|
||||
width?: number;
|
||||
priority?: boolean;
|
||||
className?: string;
|
||||
/** Decorative by default — pass a label when the logo carries meaning. */
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a brand asset at the correct aspect ratio.
|
||||
* Previously the 1344×768 mark was hard-coded as 36×36, 120×80, 180×120…
|
||||
* in six different files, which squashed the mountain differently each time.
|
||||
*/
|
||||
export function Logo({
|
||||
variant = "mark",
|
||||
width = 40,
|
||||
priority = false,
|
||||
className = "",
|
||||
alt = "",
|
||||
}: Props) {
|
||||
const asset = BRAND[variant];
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={asset.src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={Math.round((width * asset.height) / asset.width)}
|
||||
priority={priority}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { SectionHeading } from "./SectionHeading";
|
||||
|
||||
type Props = {
|
||||
eyebrow?: string;
|
||||
title: ReactNode;
|
||||
lead?: ReactNode;
|
||||
width?: "narrow" | "default" | "wide";
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const widths = {
|
||||
narrow: "max-w-3xl",
|
||||
default: "max-w-6xl",
|
||||
wide: "max-w-7xl",
|
||||
};
|
||||
|
||||
/**
|
||||
* Top block of an inner page. Carries the offset for the fixed navbar
|
||||
* (previously a `pt-32` copy-pasted into every page).
|
||||
*/
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
lead,
|
||||
width = "default",
|
||||
children,
|
||||
}: Props) {
|
||||
return (
|
||||
<section className="px-6 pb-12 pt-32 sm:pb-16 sm:pt-40 lg:px-10">
|
||||
<div className={`mx-auto w-full ${widths[width]}`}>
|
||||
<SectionHeading
|
||||
level="page"
|
||||
eyebrow={eyebrow}
|
||||
title={title}
|
||||
lead={lead}
|
||||
/>
|
||||
{children && <div className="mt-10">{children}</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion, useScroll, useTransform } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
/** Pixels travelled by the end of `range`. Positive = drifts down. */
|
||||
distance?: number;
|
||||
/** Pixels of page scroll over which the movement happens. */
|
||||
range?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Slow drift on a hero background as the page scrolls away from it.
|
||||
*
|
||||
* Reads the raw page scroll rather than measuring its own position: this is
|
||||
* only ever used at the top of a document, and measuring an element that is
|
||||
* itself being transformed feeds its own output back into the input.
|
||||
*/
|
||||
export function Parallax({
|
||||
children,
|
||||
distance = 90,
|
||||
range = 700,
|
||||
className,
|
||||
}: Props) {
|
||||
const reduced = useReducedMotion();
|
||||
const { scrollY } = useScroll();
|
||||
const y = useTransform(scrollY, [0, range], [0, distance], { clamp: true });
|
||||
|
||||
if (reduced) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div style={{ y }} className={className}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
import { EASE_OUT_EXPO, VIEWPORT } from "@/lib/motion";
|
||||
|
||||
/** Where the element travels from. `up` is the site default. */
|
||||
type Direction = "up" | "down" | "left" | "right" | "scale" | "fade";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
/** Stagger helper — seconds added before the animation starts. */
|
||||
delay?: number;
|
||||
direction?: Direction;
|
||||
/** Seconds. Longer for large blocks, shorter for small ones. */
|
||||
duration?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const from: Record<Direction, Record<string, number>> = {
|
||||
up: { opacity: 0, y: 24 },
|
||||
down: { opacity: 0, y: -24 },
|
||||
left: { opacity: 0, x: -36 },
|
||||
right: { opacity: 0, x: 36 },
|
||||
scale: { opacity: 0, scale: 0.94 },
|
||||
fade: { opacity: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Scroll-triggered entrance. The single animation primitive used across the
|
||||
* site so every section enters the same way — the direction only changes when
|
||||
* the layout gives it a reason to (a panel that sits on the right enters from
|
||||
* the right).
|
||||
* Honours `prefers-reduced-motion`: content is then rendered statically.
|
||||
*/
|
||||
export function Reveal({
|
||||
children,
|
||||
delay = 0,
|
||||
direction = "up",
|
||||
duration = 0.6,
|
||||
className,
|
||||
}: Props) {
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
if (reduced) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={from[direction]}
|
||||
whileInView={{ opacity: 1, x: 0, y: 0, scale: 1 }}
|
||||
viewport={VIEWPORT}
|
||||
transition={{ duration, delay, ease: EASE_OUT_EXPO }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion, useScroll, useSpring } from "framer-motion";
|
||||
|
||||
/**
|
||||
* Reading progress, rendered as the navbar's bottom hairline.
|
||||
*
|
||||
* Deliberately not a bar of its own: the header already draws a 1px border
|
||||
* once the page is scrolled, so the progress line replaces something that was
|
||||
* there rather than adding furniture to the top of every page. At the top of
|
||||
* the document it has zero width and is invisible.
|
||||
*/
|
||||
export function ScrollProgress() {
|
||||
const reduced = useReducedMotion();
|
||||
const { scrollYProgress } = useScroll();
|
||||
|
||||
// The spring smooths the jitter of a trackpad; with reduced motion the raw
|
||||
// value is used, which still tracks the scroll exactly — just without easing.
|
||||
const smoothed = useSpring(scrollYProgress, {
|
||||
stiffness: 120,
|
||||
damping: 30,
|
||||
restDelta: 0.001,
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
aria-hidden
|
||||
style={{ scaleX: reduced ? scrollYProgress : smoothed }}
|
||||
className="absolute inset-x-0 bottom-0 h-px origin-left bg-accent/70"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Spacing = "default" | "tight" | "none";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
id?: string;
|
||||
/** Content max-width. `wide` for galleries, `narrow` for reading columns. */
|
||||
width?: "narrow" | "default" | "wide";
|
||||
/** Top hairline separating this section from the previous one. */
|
||||
divider?: boolean;
|
||||
/** `none` when the section continues the previous block without a break. */
|
||||
top?: Spacing;
|
||||
bottom?: Spacing;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const widths = {
|
||||
narrow: "max-w-3xl",
|
||||
default: "max-w-6xl",
|
||||
wide: "max-w-7xl",
|
||||
};
|
||||
|
||||
const topPadding: Record<Spacing, string> = {
|
||||
default: "pt-20 sm:pt-28",
|
||||
tight: "pt-10 sm:pt-14",
|
||||
none: "pt-0",
|
||||
};
|
||||
|
||||
const bottomPadding: Record<Spacing, string> = {
|
||||
default: "pb-20 sm:pb-28",
|
||||
tight: "pb-10 sm:pb-14",
|
||||
none: "pb-0",
|
||||
};
|
||||
|
||||
/**
|
||||
* Consistent vertical rhythm and horizontal gutters for every page section.
|
||||
* Replaces the ad-hoc `py-24 px-6 lg:px-10 max-w-6xl mx-auto` that used to be
|
||||
* repeated in every page.
|
||||
*/
|
||||
export function Section({
|
||||
children,
|
||||
id,
|
||||
width = "default",
|
||||
divider = false,
|
||||
top = "default",
|
||||
bottom = "default",
|
||||
className = "",
|
||||
}: Props) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={`px-6 lg:px-10 ${topPadding[top]} ${bottomPadding[bottom]} ${
|
||||
divider ? "border-t border-border" : ""
|
||||
} ${className}`}
|
||||
>
|
||||
<div className={`mx-auto w-full ${widths[width]}`}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
/** Small uppercase kicker above the title. */
|
||||
eyebrow?: string;
|
||||
title: ReactNode;
|
||||
/** Supporting paragraph under the title. */
|
||||
lead?: ReactNode;
|
||||
/** Optional link/button aligned to the right on wide screens. */
|
||||
action?: ReactNode;
|
||||
align?: "left" | "center";
|
||||
/** `page` for h1-sized headings, `section` for h2. */
|
||||
level?: "page" | "section";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The heading pattern used at the top of every section: eyebrow, title, lead.
|
||||
* Sizes are deliberately smaller on mobile — the previous 6xl/7xl display
|
||||
* type overflowed narrow screens.
|
||||
*/
|
||||
export function SectionHeading({
|
||||
eyebrow,
|
||||
title,
|
||||
lead,
|
||||
action,
|
||||
align = "left",
|
||||
level = "section",
|
||||
className = "",
|
||||
}: Props) {
|
||||
const Tag = level === "page" ? "h1" : "h2";
|
||||
const size =
|
||||
level === "page"
|
||||
? "text-4xl sm:text-6xl lg:text-7xl"
|
||||
: "text-3xl sm:text-4xl lg:text-5xl";
|
||||
const centered = align === "center";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-6 ${
|
||||
action ? "lg:flex-row lg:items-end lg:justify-between" : ""
|
||||
} ${centered ? "items-center text-center" : ""} ${className}`}
|
||||
>
|
||||
<div className={centered ? "max-w-2xl" : ""}>
|
||||
{eyebrow && (
|
||||
<p className="font-display text-xs sm:text-sm tracking-[0.3em] text-accent mb-4">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<Tag
|
||||
className={`font-display ${size} tracking-wider leading-[1.05] text-balance`}
|
||||
>
|
||||
{title}
|
||||
</Tag>
|
||||
{lead && (
|
||||
<p
|
||||
className={`mt-6 text-base sm:text-lg text-foreground-muted leading-relaxed max-w-2xl ${
|
||||
centered ? "mx-auto" : ""
|
||||
}`}
|
||||
>
|
||||
{lead}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Pending indicator for form submit buttons. Decorative — the button label
|
||||
* already says "SENDING…", so a screen reader gets the state from the text. */
|
||||
export function Spinner({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M21 12a9 9 0 0 0-9-9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* A faint glow that follows the pointer across a card.
|
||||
*
|
||||
* Drop it as the last child of any positioned element carrying Tailwind's
|
||||
* `group` class — it finds its own parent, so the card itself can stay a
|
||||
* server component and needs no props threaded through it:
|
||||
*
|
||||
* <Link className="group relative …">
|
||||
* …
|
||||
* <Spotlight />
|
||||
* </Link>
|
||||
*
|
||||
* The paint lives in `.spotlight` (globals.css); this only writes the pointer
|
||||
* position. Nothing runs until the pointer is actually over the card, and the
|
||||
* effect is disabled on touch screens, where `:hover` would keep it lit.
|
||||
*/
|
||||
export function Spotlight() {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const card = ref.current?.parentElement;
|
||||
if (!card || !window.matchMedia("(hover: hover)").matches) return;
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
card.style.setProperty("--spot-x", `${event.clientX - rect.left}px`);
|
||||
card.style.setProperty("--spot-y", `${event.clientY - rect.top}px`);
|
||||
};
|
||||
|
||||
card.addEventListener("pointermove", onMove);
|
||||
return () => card.removeEventListener("pointermove", onMove);
|
||||
}, []);
|
||||
|
||||
return <span ref={ref} aria-hidden className="spotlight" />;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
import { EASE_OUT_EXPO, VIEWPORT } from "@/lib/motion";
|
||||
|
||||
const containers = {
|
||||
div: motion.div,
|
||||
ul: motion.ul,
|
||||
ol: motion.ol,
|
||||
} as const;
|
||||
|
||||
const items = {
|
||||
div: motion.div,
|
||||
li: motion.li,
|
||||
span: motion.span,
|
||||
} as const;
|
||||
|
||||
type ContainerTag = keyof typeof containers;
|
||||
type ItemTag = keyof typeof items;
|
||||
|
||||
const item: Variants = {
|
||||
hidden: { opacity: 0, y: 18 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5, ease: EASE_OUT_EXPO },
|
||||
},
|
||||
};
|
||||
|
||||
type StaggerProps = {
|
||||
children: ReactNode;
|
||||
/** Seconds between two children. */
|
||||
step?: number;
|
||||
/** Seconds before the first child moves. */
|
||||
delay?: number;
|
||||
as?: ContainerTag;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A list or grid whose children enter one after the other instead of all at
|
||||
* once — the difference between "a block appeared" and "these items were
|
||||
* dealt out".
|
||||
*
|
||||
* Use it in place of `Reveal` wherever the content is a collection: cards,
|
||||
* figures, thumbnails. A single block stays on `Reveal`.
|
||||
* Pair with `StaggerItem` — a plain child is not animated.
|
||||
*/
|
||||
export function Stagger({
|
||||
children,
|
||||
step = 0.08,
|
||||
delay = 0,
|
||||
as = "div",
|
||||
className,
|
||||
}: StaggerProps) {
|
||||
const reduced = useReducedMotion();
|
||||
const Tag = containers[as];
|
||||
|
||||
if (reduced) {
|
||||
const Plain = as;
|
||||
return <Plain className={className}>{children}</Plain>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={VIEWPORT}
|
||||
variants={{
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: step, delayChildren: delay } },
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
type StaggerItemProps = {
|
||||
children: ReactNode;
|
||||
as?: ItemTag;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function StaggerItem({
|
||||
children,
|
||||
as = "div",
|
||||
className,
|
||||
}: StaggerItemProps) {
|
||||
const reduced = useReducedMotion();
|
||||
const Tag = items[as];
|
||||
|
||||
if (reduced) {
|
||||
const Plain = as;
|
||||
return <Plain className={className}>{children}</Plain>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag variants={item} className={className}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { STATUS_LABEL, type GameStatus } from "@/lib/games";
|
||||
|
||||
const tones: Record<GameStatus, string> = {
|
||||
"in-development": "border-accent/50 text-accent",
|
||||
"coming-soon": "border-accent/50 text-accent",
|
||||
released: "border-foreground/40 text-foreground",
|
||||
};
|
||||
|
||||
/** A game still being worked on gets a live dot; a shipped one does not. */
|
||||
const live: Record<GameStatus, boolean> = {
|
||||
"in-development": true,
|
||||
"coming-soon": true,
|
||||
released: false,
|
||||
};
|
||||
|
||||
/** Production status pill. Always on a solid surface, never over artwork. */
|
||||
export function StatusBadge({ status }: { status: GameStatus }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-2 border px-3 py-1 font-display text-[0.7rem] tracking-[0.25em] ${tones[status]}`}
|
||||
>
|
||||
{live[status] && (
|
||||
<span aria-hidden className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-50" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
||||
</span>
|
||||
)}
|
||||
{STATUS_LABEL[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user