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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user