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
+70
View File
@@ -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>
);
}
+84
View File
@@ -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>
);
}
+163
View File
@@ -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>
);
}
+30
View File
@@ -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>
);
}
+97
View File
@@ -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>
);
}