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
+90 -117
View File
@@ -1,8 +1,13 @@
import Link from "next/link";
import type { Metadata } from "next";
import { PageHeader } from "../components/ui/PageHeader";
import { Section } from "../components/ui/Section";
import { SectionHeading } from "../components/ui/SectionHeading";
import { PostList } from "../components/devblog/PostList";
import { PostFeature } from "../components/devblog/PostFeature";
import { Newsletter } from "../components/forms/Newsletter";
import { getAllPosts } from "@/lib/devblog";
import { games as gameRegistry } from "@/lib/games";
import { Newsletter } from "../components/Newsletter";
import { games } from "@/lib/games";
export const metadata: Metadata = {
title: "Devblog",
@@ -12,14 +17,6 @@ export const metadata: Metadata = {
const PAGE_SIZE = 10;
function formatDate(date: string) {
return new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
function buildHref(params: { game?: string; page?: number }) {
const search = new URLSearchParams();
if (params.game) search.set("game", params.game);
@@ -28,6 +25,13 @@ function buildHref(params: { game?: string; page?: number }) {
return qs ? `/devblog?${qs}` : "/devblog";
}
const filterClass = (active: boolean) =>
`border px-3 py-1.5 text-xs uppercase tracking-widest transition duration-200 ease-out active:scale-95 ${
active
? "border-accent text-accent"
: "border-border text-foreground-muted hover:-translate-y-0.5 hover:border-accent hover:text-accent"
}`;
type Props = {
searchParams: Promise<{ game?: string; page?: string }>;
};
@@ -36,138 +40,103 @@ export default async function DevblogPage({ searchParams }: Props) {
const { game, page } = await searchParams;
const allPosts = getAllPosts();
const usedGameSlugs = Array.from(
new Set(allPosts.map((p) => p.game).filter((g): g is string => Boolean(g))),
);
const gameOptions = usedGameSlugs
// Only offer a filter for games that actually have posts.
const gameOptions = Array.from(
new Set(allPosts.map((post) => post.game).filter(Boolean) as string[]),
)
.map((slug) => ({
slug,
title: gameRegistry.find((g) => g.slug === slug)?.title ?? slug,
title: games.find((g) => g.slug === slug)?.title ?? slug,
}))
.sort((a, b) => a.title.localeCompare(b.title));
const filtered = allPosts.filter((p) => {
if (game && p.game !== game) return false;
return true;
});
const filtered = game
? allPosts.filter((post) => post.game === game)
: allPosts;
const currentPage = Math.max(1, parseInt(page ?? "1", 10) || 1);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const currentPage = Math.max(1, Number.parseInt(page ?? "1", 10) || 1);
const safePage = Math.min(currentPage, totalPages);
const visible = filtered.slice(
(safePage - 1) * PAGE_SIZE,
safePage * PAGE_SIZE,
);
return (
<div className="pt-32 pb-24 px-6 lg:px-10">
<div className="max-w-4xl mx-auto">
<p className="font-display text-sm tracking-[0.3em] text-accent mb-4">
DEVBLOG
</p>
<h1 className="font-display text-5xl sm:text-6xl lg:text-7xl tracking-wider leading-[0.95] mb-8">
THE JOURNEY,
<br />
ONE STEP AT A TIME.
</h1>
<p className="text-lg text-foreground-muted leading-relaxed max-w-2xl mb-12">
Honest updates from the studio. Design decisions, prototypes, art
milestones everything that goes into building our worlds.
</p>
// The newest post leads the page, but only where "newest" means anything:
// not on page two, and not inside a filtered view.
const featured = safePage === 1 && !game ? visible[0] : undefined;
const listed = featured ? visible.slice(1) : visible;
{gameOptions.length > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-12">
<span className="font-display text-xs tracking-[0.3em] text-foreground-muted mr-2">
return (
<>
<PageHeader
eyebrow="DEVBLOG"
width="narrow"
title={
<>
THE JOURNEY,
<br />
ONE STEP AT A TIME.
</>
}
lead="Honest updates from the studio: design decisions, prototypes, art milestones, and everything else that goes into building our worlds."
/>
<Section top="none" width="narrow">
{/* One game means the filter offers "All" and "All, again". */}
{gameOptions.length > 1 && (
<div className="mb-10 flex flex-wrap items-center gap-2">
<span className="mr-2 font-display text-xs tracking-[0.3em] text-foreground-muted">
GAME
</span>
<Link
href={buildHref({})}
className={`text-xs tracking-widest uppercase border px-3 py-1.5 transition-colors ${
!game
? "border-accent text-accent"
: "border-border text-foreground-muted hover:border-accent hover:text-accent"
}`}
>
<Link href={buildHref({})} className={filterClass(!game)}>
All
</Link>
{gameOptions.map((g) => (
{gameOptions.map((option) => (
<Link
key={g.slug}
href={buildHref({ game: g.slug })}
className={`text-xs tracking-widest uppercase border px-3 py-1.5 transition-colors ${
game === g.slug
? "border-accent text-accent"
: "border-border text-foreground-muted hover:border-accent hover:text-accent"
}`}
key={option.slug}
href={buildHref({ game: option.slug })}
className={filterClass(game === option.slug)}
>
{g.title}
{option.title}
</Link>
))}
</div>
)}
{visible.length === 0 ? (
{visible.length === 0 && (
<p className="text-foreground-muted">
{game
? "No posts match these filters yet."
? "No posts for this game yet."
: "No posts yet. Check back soon."}
</p>
) : (
<div className="space-y-1">
{visible.map((post) => {
const postGame = post.game
? gameRegistry.find((g) => g.slug === post.game)?.title ??
post.game
: null;
return (
<Link
key={post.slug}
href={`/devblog/${post.slug}`}
className="group block py-8 border-t border-border hover:border-accent transition-colors"
>
<div className="flex flex-col sm:flex-row sm:items-baseline sm:justify-between gap-2 mb-3">
<h2 className="font-display text-2xl sm:text-3xl tracking-wider group-hover:text-accent transition-colors">
{post.title.toUpperCase()}
</h2>
<time
dateTime={post.date}
className="text-xs tracking-widest text-foreground-muted shrink-0"
>
{formatDate(post.date)}
</time>
</div>
{post.excerpt && (
<p className="text-foreground-muted leading-relaxed max-w-2xl">
{post.excerpt}
</p>
)}
{postGame && (
<div className="flex flex-wrap items-center gap-2 mt-4">
<span className="text-[0.65rem] tracking-widest uppercase border border-accent/40 text-accent px-2 py-1">
{postGame}
</span>
</div>
)}
<span className="inline-block mt-4 font-display text-xs tracking-widest text-foreground-muted group-hover:text-accent transition-colors">
READ
</span>
</Link>
);
})}
)}
{featured && <PostFeature post={featured} eyebrow="Latest" as="h2" />}
{listed.length > 0 && (
<div className={featured ? "mt-12" : undefined}>
<PostList posts={listed} />
</div>
)}
{totalPages > 1 && (
<nav
aria-label="Pagination"
className="flex items-center justify-between mt-12 pt-8 border-t border-border"
className="mt-12 flex items-center justify-between"
>
{safePage > 1 ? (
<Link
href={buildHref({ game, page: safePage - 1 })}
className="font-display text-xs tracking-widest text-foreground-muted hover:text-accent transition-colors"
className="group inline-flex items-center gap-2 font-display text-xs tracking-widest text-foreground-muted transition-colors hover:text-accent"
>
NEWER
<span
aria-hidden
className="inline-block transition-transform duration-300 ease-out group-hover:-translate-x-1"
>
</span>
NEWER
</Link>
) : (
<span aria-hidden />
@@ -178,29 +147,33 @@ export default async function DevblogPage({ searchParams }: Props) {
{safePage < totalPages ? (
<Link
href={buildHref({ game, page: safePage + 1 })}
className="font-display text-xs tracking-widest text-foreground-muted hover:text-accent transition-colors"
className="group inline-flex items-center gap-2 font-display text-xs tracking-widest text-foreground-muted transition-colors hover:text-accent"
>
OLDER
OLDER
<span
aria-hidden
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1"
>
</span>
</Link>
) : (
<span aria-hidden />
)}
</nav>
)}
</Section>
<section className="mt-24 border-t border-border pt-16">
<p className="font-display text-xs tracking-[0.3em] text-accent mb-3">
NEWSLETTER
</p>
<h2 className="font-display text-3xl sm:text-4xl tracking-wider mb-4">
DON&apos;T MISS A SUMMIT
</h2>
<p className="text-foreground-muted leading-relaxed max-w-2xl mb-6">
Subscribe to get devblog highlights and release news in your inbox.
</p>
<Section divider width="narrow">
<SectionHeading
eyebrow="NEWSLETTER"
title="DON'T MISS A SUMMIT"
lead="Subscribe to get devblog highlights and release news in your inbox."
/>
<div className="mt-10">
<Newsletter variant="compact" />
</section>
</div>
</div>
</div>
</Section>
</>
);
}