Ajoute une couche de micro-animations sur tout le site
Deploy / deploy (push) Successful in 1m13s

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:
2026-08-02 16:04:41 +02:00
parent 8fd9ca88cc
commit 9b6638eb00
66 changed files with 3938 additions and 1469 deletions
+61
View File
@@ -0,0 +1,61 @@
import { Reveal } from "../ui/Reveal";
import { CountUp } from "../ui/CountUp";
type Props = {
/** e.g. "1-6". Rendered as "16" 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>
);
}
+85
View File
@@ -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>
);
}
+125
View File
@@ -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>
);
}
+62
View File
@@ -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>
);
}
+237
View File
@@ -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>
</>
);
}