feat: homepage animations; Arabic as default locale
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user