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
+9 -1
View File
@@ -40,6 +40,8 @@ export interface AccordionProps {
*/
defaultOpenIndex?: number | null;
className?: string;
/** Extra classes for each question row, e.g. a scroll-driven reveal. */
itemClassName?: string;
}
/**
@@ -75,7 +77,12 @@ function ToggleMark({ open }: { open: boolean }) {
);
}
export function Accordion({ items, defaultOpenIndex = 0, className }: AccordionProps) {
export function Accordion({
items,
defaultOpenIndex = 0,
className,
itemClassName,
}: AccordionProps) {
const baseId = useId();
const initial =
defaultOpenIndex === null ? null : (items[defaultOpenIndex]?.id ?? null);
@@ -97,6 +104,7 @@ export function Accordion({ items, defaultOpenIndex = 0, className }: AccordionP
"border-b border-[var(--border-default)]",
"transition-colors duration-[var(--motion-duration-base)]",
"hover:bg-[var(--surface-muted)]",
itemClassName,
)}
>
<button
+103
View File
@@ -268,6 +268,39 @@
animation-range: entry 0% entry 80%;
}
/* The edge tiles of a row land in from their own side of the line - the
reading start and the reading end - instead of rising, so a row drags
into place edge-first while its middle rises. `translateX` has no
logical keyword, so the RTL block swaps the two names: the drift always
comes off the start edge, whichever script is on screen. */
.sh-reveal--from-start {
animation-name: sh-rise-from-start;
}
.sh-reveal--from-end {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-start {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-end {
animation-name: sh-rise-from-start;
}
/* A beat between the tiles of a row: each item after the first holds at
its hidden state until it is a little deeper in the viewport, so a row
cascades start-to-end instead of landing as one. Only the start of the
range shifts; `.sh-reveal--in` and the edge slides keep their ends. */
.sh-reveal--step-1 {
animation-range-start: entry 12%;
}
.sh-reveal--step-2 {
animation-range-start: entry 24%;
}
/* The hero picture drifting as the page scrolls past it. It is scaled up
before it travels, so the drift can never uncover an edge of the
frame. Chromium-only, like every timeline here: elsewhere the picture
@@ -306,6 +339,49 @@
transform-origin: right center;
}
}
/* THE SAME REVEAL, WITHOUT A TIMELINE. `_page-kit/reveal.ts` tags a
`.sh-reveal` element `.sh-reveal-js` (hidden) in browsers that lack
scroll timelines, then `.sh-reveal-js--in` plays `sh-rise` once it
scrolls into view. Both classes are inert without the helper, so
content is always readable. */
.sh-reveal-js {
opacity: 0;
}
.sh-reveal-js--in {
animation: sh-rise var(--motion-duration-reveal)
var(--motion-ease-entrance) both;
}
/* The edge tiles keep their drift here too: the reader who never sees a
scroll timeline still gets the same from-start / from-end arrival. The
RTL mirrors swap the names, as the Chromium block above does. */
.sh-reveal--from-start.sh-reveal-js--in {
animation-name: sh-rise-from-start;
}
.sh-reveal--from-end.sh-reveal-js--in {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-start.sh-reveal-js--in {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-end.sh-reveal-js--in {
animation-name: sh-rise-from-start;
}
/* The same beat, expressed in time where there is no timeline. `both`
already answers for the hidden hold during the delay. */
.sh-reveal--step-1.sh-reveal-js--in {
animation-delay: var(--motion-duration-fast);
}
.sh-reveal--step-2.sh-reveal-js--in {
animation-delay: calc(var(--motion-duration-fast) * 2);
}
}
}
@@ -333,6 +409,33 @@
}
}
/* The same reveal turned sideways. `--from-start` is picked up by the RTL
block as the mirror of `--from-end`, so both names exist and the swap is
legible there instead of shipping one keyframe and praying. */
@keyframes sh-rise-from-start {
from {
opacity: 0;
transform: translateX(-20px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sh-rise-from-end {
from {
opacity: 0;
transform: translateX(20px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sh-bar-grow {
from {
transform: scaleX(0);
+45
View File
@@ -0,0 +1,45 @@
/* Reveal-on-scroll for browsers without scroll-driven timelines.
----------------------------------------------------------------------------
motion.css handles `.sh-reveal` with `animation-timeline: view()`, which is
Chromium-only; elsewhere every band sits at rest, fully visible. This helper
gives those browsers the same `.sh-rise` reveal: it tags each `.sh-reveal`
element `.sh-reveal-js` (hidden), then flips it to `.sh-reveal-js--in` (the
animation runs) once it scrolls into view.
It is a progressive enhancement and is strictly opt-in per page:
- Chromium already animates via CSS, so this returns early and never
fights the scroll-driven version.
- Reduced-motion visitors are left alone (the CSS never hides them).
- With JS off, no classes are ever added, so content stays visible.
Renders nothing on the server; call from a `useEffect` in the page. */
const SELECTOR = ".sh-reveal";
export function enableReveals(): void {
if (typeof window === "undefined") return;
if (!("IntersectionObserver" in window)) return;
if ("CSS" in window && CSS.supports("animation-timeline: view()")) {
/* Chromium already plays the reveal; nothing to add. */
return;
}
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const element = entry.target;
element.classList.add("sh-reveal-js--in");
element.classList.remove("sh-reveal-js");
observer.unobserve(element);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const element of document.querySelectorAll<HTMLElement>(SELECTOR)) {
element.classList.add("sh-reveal-js");
observer.observe(element);
}
}
+7 -4
View File
@@ -20,7 +20,7 @@
2. The route's `meta` appends `detailAlternateDescriptors(...)` to whatever
`buildMeta` produced, which emits <link rel="canonical"> plus one
<link rel="alternate" hreflang> per locale that actually has a slug, and
x-default pointing at English when English exists.
x-default pointing at the default locale when it exists.
3. The route declares `export const handle = SELF_ALTERNATES_HANDLE`, which
tells the app shell that this page emits its own pair and that the
shell's prefix-swapped set must be suppressed for it. The shell reads it
@@ -33,7 +33,7 @@
import type { MetaDescriptor } from "react-router";
import { SUPPORTED_LOCALES, type Locale } from "~/i18n/config";
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from "~/i18n/config";
import { sectionPath, type RouteSlugKey } from "~/i18n/paths";
import { canonicalUrl } from "~/lib/seo";
@@ -160,8 +160,11 @@ export function detailAlternateDescriptors({
});
}
// x-default is the English URL, because bare "/" negotiates towards English.
const fallback = paths.find((entry) => entry.locale === "en");
// x-default is the default-locale URL, because bare "/" negotiates towards
// the default locale. It must point at an existing page, so a detail page
// with no translation yet falls back to whichever locale does exist.
const fallback =
paths.find((entry) => entry.locale === DEFAULT_LOCALE) ?? paths[0];
if (fallback) {
descriptors.push({
tagName: "link",
+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}