feat: homepage animations; Arabic as default locale

This commit is contained in:
2026-09-24 00:22:40 +02:00
parent 2ff5cf47cf
commit 1879a206ad
14 changed files with 268 additions and 56 deletions
+67 -16
View File
@@ -36,13 +36,16 @@
MOTION: every animation in the draft is here, as classes from
_page-kit/motion.css - the headline wipe, the hero picture's scroll-driven
drift, the gallery and card hovers, the logo marquee. The one effect
deliberately dropped is the coloured glow behind a hovered button or card.
drift, each item tasting in as it scrolls into view (the edge tiles of a row
sliding in from their own side, the middles and stacked lists rising), the
gallery and card hovers, the logo marquee. The one effect deliberately
dropped is the coloured glow behind a hovered button or card.
There is no Arabic frame, so /ar is this same DOM under `dir="rtl"`, which
is why every inline edge below is logical (ps-/pe-/start-/end-).
========================================================================= */
import { useEffect } from "react";
import { Link, useRouteLoaderData } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
@@ -65,6 +68,7 @@ import { DEFAULT_THEME, type AppTheme } from "~/lib/theme";
import type { Route } from "./+types/home";
import { Accordion } from "./_page-kit/accordion";
import { enableReveals } from "./_page-kit/reveal";
import {
isImageGalleryBlock,
type CmsFaq,
@@ -365,6 +369,25 @@ function StatusPill({ label }: { label: string }) {
* vertically centred on the middle. One or two pictures fall back to the
* uniform grid, where the mosaic's "faded flank" reading has no partner.
*/
/* The edge items of a row land in from their own side of the line - the start
or the end - while the middle ones rise, so a row reads as arriving edge-first
instead of lifting as one. A lone item simply rises. */
function revealEdge(index: number, count: number): string {
if (count < 2) return "";
if (index === 0) return "sh-reveal--from-start";
if (index === count - 1) return "sh-reveal--from-end";
return "";
}
/* A beat between the tiles of a row: each item after the first holds at its
hidden state a moment longer, so a row cascades start-to-end. Rows here cap
at three tiles, so two steps are enough. */
function revealStep(index: number): string {
if (index === 1) return "sh-reveal--step-1";
if (index >= 2) return "sh-reveal--step-2";
return "";
}
function GalleryShots({ images }: { images: readonly ResolvedImage[] }) {
if (images.length === 0) return null;
@@ -380,7 +403,10 @@ function GalleryShots({ images }: { images: readonly ResolvedImage[] }) {
the draft's 60px. */
<ul className="grid list-none grid-cols-1 gap-3xl lg:grid-cols-[427fr_605fr_426fr] lg:items-center">
{images.map((image, index) => (
<li key={image.src} className="sh-reveal">
<li
key={image.src}
className={cx("sh-reveal", revealEdge(index, images.length), revealStep(index))}
>
<figure className="flex flex-col gap-xs">
<div
className={cx(
@@ -409,8 +435,11 @@ function GalleryShots({ images }: { images: readonly ResolvedImage[] }) {
return (
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
{images.map((image) => (
<li key={image.src} className="sh-reveal">
{images.map((image, index) => (
<li
key={image.src}
className={cx("sh-reveal", revealEdge(index, images.length), revealStep(index))}
>
<figure className="flex flex-col gap-xs">
<div className="sh-lift sh-lift--grow rounded-control border border-[var(--border-subtle)] hover:border-[var(--border-accent)]">
<img
@@ -521,12 +550,20 @@ export default function Home({ loaderData }: Route.ComponentProps) {
exactly as the footer's badge does: same root cookie, same emblem. */
const root = useRouteLoaderData<{ theme?: AppTheme }>("root");
const theme: AppTheme = root?.theme ?? DEFAULT_THEME;
/* Chromium plays every item reveal natively via scroll-driven timelines
(motion.css). For non-Chromium browsers `_page-kit/reveal.ts` flips each
`.sh-reveal` item to `.sh-reveal-js--in` as it enters the viewport, so the
fade-and-drift lands everywhere - while reduced-motion visitors and users
without JS keep every item fully visible. */
useEffect(() => {
enableReveals();
}, []);
/* The three pillars are the About page's approved copy - one source, so the
two pages can never drift apart. */
const pillars = pageCopy(locale).about.pillars;
const headline = hero.headline ?? copy.heroHeadline;
const lead = hero.subheading ?? copy.heroLead;
// const lead = hero.subheading ?? copy.heroLead;
const consortiumHeading = loaderData.partnersHeadline ?? copy.consortiumHeading;
/* The frame draws one accent CTA and one white one. Editor-supplied CTAs go
@@ -564,7 +601,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
<PageHero
className="bg-[var(--surface-sunken)] [--layout-hero-top:clamp(2.5rem,6vw,4.5rem)]"
title={headline}
lead={lead}
lead={""}
leadWithActions
{...(hero.eyebrow ? { statusPill: <StatusPill label={hero.eyebrow} /> } : {})}
ambient={[
@@ -651,12 +688,18 @@ reveal
</div>
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
{pillars.map((pillar) => (
{pillars.map((pillar, index) => (
<li
key={pillar.title}
/* `sh-rule-top` draws the draft's accent rule across the top
of the tile as the pointer arrives. */
className="sh-lift sh-reveal sh-reveal--in sh-rule-top flex h-full flex-col gap-sm rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl hover:border-[var(--border-accent)]"
/* `sh-reveal--in` adds the draft's rise-and-scale; the edge
tiles of the row slide in from their own side instead.
`sh-rule-top` draws the accent rule across the top of the
tile as the pointer arrives. */
className={cx(
"sh-lift sh-reveal sh-reveal--in sh-rule-top flex h-full flex-col gap-sm rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl hover:border-[var(--border-accent)]",
revealEdge(index, pillars.length),
revealStep(index),
)}
>
<h3 className="heading-h3 text-[var(--text-primary)]">{pillar.title}</h3>
<p className="body-md text-[var(--text-secondary)]">
@@ -687,11 +730,15 @@ reveal
{news.length > 0 ? (
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
{news.map((item) => (
{news.map((item, index) => (
<Card.Content
key={item.id}
as="li"
className="sh-reveal sh-reveal--in"
className={cx(
"sh-reveal sh-reveal--in",
revealEdge(index, news.length),
revealStep(index),
)}
href={item.href}
title={item.title}
actionLabel={t("actions.readMore")}
@@ -744,7 +791,7 @@ reveal
</div>
{faqs.length > 0 ? (
<Accordion items={faqs} className="sh-reveal" />
<Accordion items={faqs} itemClassName="sh-reveal" />
) : (
<EmptyState message={copy.faqEmpty} />
)}
@@ -770,10 +817,14 @@ reveal
</div>
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
{copy.consortium.map((member) => (
{copy.consortium.map((member, index) => (
<li
key={member.name}
className="sh-lift sh-reveal sh-reveal--in flex h-full flex-col items-start gap-sm rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl hover:border-[var(--border-accent)]"
className={cx(
"sh-lift sh-reveal sh-reveal--in flex h-full flex-col items-start gap-sm rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl hover:border-[var(--border-accent)]",
revealEdge(index, copy.consortium.length),
revealStep(index),
)}
>
<span className="rounded-chip bg-[var(--surface-accent-subtle)] px-xs py-2xs label-md uppercase text-[var(--text-accent)]">
{member.role}