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
+4 -4
View File
@@ -37,7 +37,7 @@ const deniedTypes = [
/**
* The locales the site ships with.
*
* `en` is the default (LTR) and `ar` is the right-to-left translation. The web
* `ar` is the default (RTL) and `en` is the left-to-right translation. The web
* app mirrors these codes in its URL prefixes (`/en/...`, `/ar/...`), so the
* codes here are part of the public contract - do not rename them.
*
@@ -47,8 +47,8 @@ const deniedTypes = [
* here plus a restart.
*/
const locales = [
{ code: 'en', name: 'English (en)', isDefault: true },
{ code: 'ar', name: 'Arabic (ar)' },
{ code: 'ar', name: 'Arabic (ar)', isDefault: true },
{ code: 'en', name: 'English (en)' },
];
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin => ({
@@ -59,7 +59,7 @@ const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin =>
i18n: {
enabled: true,
config: {
defaultLocale: 'en',
defaultLocale: 'ar',
locales,
},
},
+2 -2
View File
@@ -22,8 +22,8 @@ export type LocaleDefinition = {
* is that config file.
*/
export const FALLBACK_LOCALES: LocaleDefinition[] = [
{ code: 'en', name: 'English (en)', isDefault: true },
{ code: 'ar', name: 'Arabic (ar)' },
{ code: 'ar', name: 'Arabic (ar)', isDefault: true },
{ code: 'en', name: 'English (en)' },
];
/** `type` of the users-permissions role that unauthenticated requests use. */
+1 -1
View File
@@ -247,7 +247,7 @@ async function seedDemo(
log(strapi, `${DEMO_PARTNERS.length} demo network partners`);
for (const item of DEMO_FAQS) {
await upsert(strapi, UID.faq, item, { question: { $eq: item.en.question } });
await upsert(strapi, UID.faq, item, { question: { $eq: item.ar.question } });
}
log(strapi, `${DEMO_FAQS.length} demo FAQs`);
+12 -11
View File
@@ -7,12 +7,13 @@
explicit `--demo --clean`.
THE SHAPE OF A WRITE
A seed entry is always a pair - the English document and its Arabic
A seed entry is always a pair - the Arabic document and its English
translation - because every localised content type on this site ships in
both languages (CLAUDE.md rule 7). `upsert` writes the English document
first, because that is the one carrying the identity (the slug), then
writes the Arabic locale onto the SAME document, which is how Strapi 5
models a translation: one `documentId`, one row per locale.
both languages (CLAUDE.md rule 7) and Arabic is the CMS's default locale.
`upsert` writes the Arabic document first, because that is the one
carrying the identity (the slug), then writes the English locale onto the
SAME document, which is how Strapi 5 models a translation: one
`documentId`, one row per locale.
DRAFT AND PUBLISH
Content is written as a draft and then published explicitly. Publishing
@@ -108,7 +109,7 @@ export async function upsert(
seed ends up with duplicates. */
const existing = await service.findFirst({
...(find ? { filters: find } : {}),
locale: 'en',
locale: 'ar',
...draftFilter(strapi, uid),
});
@@ -117,16 +118,16 @@ export async function upsert(
if (existing) {
documentId = existing.documentId;
await service.update({ documentId, locale: 'en', data: data.en });
await service.update({ documentId, locale: 'ar', data: data.ar });
} else {
const document = await service.create({ locale: 'en', data: data.en });
const document = await service.create({ locale: 'ar', data: data.ar });
documentId = document.documentId;
created = true;
}
/* An update against a locale the document does not have yet creates that
translation - there is no separate "add a locale" call. */
await service.update({ documentId, locale: 'ar', data: data.ar });
await service.update({ documentId, locale: 'en', data: data.en });
if (usesDraft(strapi, uid)) {
await service.publish({ documentId, locale: '*' });
@@ -144,7 +145,7 @@ export async function upsert(
* 1. `delete({ documentId })` with no locale removes the DEFAULT locale
* only, not the document. It takes `locale: '*'` to remove the lot.
* 2. `findMany({ locale })` answers for one locale, so a document whose
* English half has already gone is invisible to a search in English.
* base half has already gone is invisible to a search in that locale.
* Both locales are searched and the documentIds pooled, which also
* sweeps up orphans an earlier, less careful run left.
*/
@@ -221,7 +222,7 @@ export async function readExisting(
): Promise<SeedDocument | null> {
return documents(strapi, uid).findFirst({
...(find ? { filters: find } : {}),
locale: 'en',
locale: 'ar',
...draftFilter(strapi, uid),
populate,
});
@@ -16,7 +16,7 @@ export type Locale = (typeof LOCALES)[number];
export type Direction = "ltr" | "rtl";
export const DEFAULT_LOCALE: Locale = "en";
export const DEFAULT_LOCALE: Locale = "ar";
/** Locale -> the value that belongs in <html dir>. */
const DIRECTION_BY_LOCALE: Record<Locale, Direction> = {
+10 -10
View File
@@ -5,8 +5,8 @@
plus {placeholder} interpolation. No i18next, no ICU MessageFormat - the
catalogues hold UI chrome only, page prose comes from Strapi.
English is the reference catalogue: `Messages` is derived from en.json, and
ar.json is typechecked against it, so a key added on one side and forgotten
Arabic is the reference catalogue: `Messages` is derived from ar.json, and
en.json is typechecked against it, so a key added on one side and forgotten
on the other is a compile error rather than a blank label in production.
========================================================================= */
@@ -15,8 +15,8 @@ import ar from "./messages/ar.json";
import { DEFAULT_LOCALE, type Locale } from "./config";
/** The shape of every catalogue. Derived from the English source of truth. */
export type Messages = typeof en;
/** The shape of every catalogue. Derived from the Arabic source of truth. */
export type Messages = typeof ar;
/** Anything that may be interpolated into a message. */
export type TranslationParams = Readonly<
@@ -44,13 +44,13 @@ export type Translator = (
params?: TranslationParams,
) => string;
/* The `Messages` annotation is the parity check: ar.json must supply every
key en.json declares, with a string in every leaf position. */
const arabicMessages: Messages = ar;
/* The `Messages` annotation is the parity check: en.json must supply every
key ar.json declares, with a string in every leaf position. */
const englishMessages: Messages = en;
const CATALOGUES: Readonly<Record<Locale, Messages>> = {
en,
ar: arabicMessages,
en: englishMessages,
ar,
};
/** The whole catalogue for a locale - handy for spreading into a component. */
@@ -90,7 +90,7 @@ export function interpolate(
}
/**
* Translate one key. Falls back to the English string when a locale is
* Translate one key. Falls back to the default-locale string when a locale is
* missing it, and finally to the key itself so a page never renders "".
*/
export function translate(
+5 -4
View File
@@ -14,6 +14,7 @@
import type { MetaDescriptor } from "react-router";
import {
DEFAULT_LOCALE,
INTL_LOCALE,
OG_LOCALE,
SUPPORTED_LOCALES,
@@ -103,8 +104,8 @@ export interface LocaleAlternate {
/**
* The hreflang set for a page: one entry per locale plus `x-default`, which
* points at the English URL because English is the default locale that bare
* "/" negotiates towards.
* points at the default-locale URL because that is what a bare "/" resolves
* to when no locale can be negotiated.
*/
export function alternateUrls(
siteUrl: string,
@@ -121,9 +122,9 @@ export function alternateUrls(
return [
...alternates,
{
locale: "en",
locale: DEFAULT_LOCALE,
hrefLang: "x-default",
href: canonicalUrl(siteUrl, localizedPath(unprefixed, "en")),
href: canonicalUrl(siteUrl, localizedPath(unprefixed, DEFAULT_LOCALE)),
},
];
}
+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}