feat: homepage animations; Arabic as default locale
This commit is contained in:
@@ -25,7 +25,7 @@ apps/web React Router 8 (framework mode, ssr: true) · React 19 · Vite 8 · T
|
|||||||
Route ids: root · locale · home · about · whatWeDo · activities · events ·
|
Route ids: root · locale · home · about · whatWeDo · activities · events ·
|
||||||
applications · news · contact · resources · knowledgeHub (+ *Detail)
|
applications · news · contact · resources · knowledgeHub (+ *Detail)
|
||||||
|
|
||||||
apps/cms Strapi 5.53 (TypeScript) · PostgreSQL 16 · i18n (en default, ar) · draft & publish
|
apps/cms Strapi 5.53 (TypeScript) · PostgreSQL 16 · i18n (ar default, en) · draft & publish
|
||||||
src/api/** content types (schema.json is the contract, commit it)
|
src/api/** content types (schema.json is the contract, commit it)
|
||||||
src/bootstrap/** runs on every boot: creates locales, grants Public permissions
|
src/bootstrap/** runs on every boot: creates locales, grants Public permissions
|
||||||
(find/findOne on content; create-only on submission types)
|
(find/findOne on content; create-only on submission types)
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ The first `pnpm dev:cms` takes a minute: Strapi creates its tables, then the boo
|
|||||||
reconciliation in `apps/cms/src/bootstrap/` runs. That step is idempotent and executes on
|
reconciliation in `apps/cms/src/bootstrap/` runs. That step is idempotent and executes on
|
||||||
every boot; it
|
every boot; it
|
||||||
|
|
||||||
- creates the `en` (default) and `ar` locales if they are missing, and
|
- creates the `ar` (default) and `en` locales if they are missing, and
|
||||||
- grants the **Public** role `find` / `findOne` on the content types the site reads, and
|
- grants the **Public** role `find` / `findOne` on the content types the site reads, and
|
||||||
`create` — and only `create` — on the three submission types (event registrations,
|
`create` — and only `create` — on the three submission types (event registrations,
|
||||||
applications, contact messages), so nobody can read other people's personal data back out.
|
applications, contact messages), so nobody can read other people's personal data back out.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const deniedTypes = [
|
|||||||
/**
|
/**
|
||||||
* The locales the site ships with.
|
* 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
|
* app mirrors these codes in its URL prefixes (`/en/...`, `/ar/...`), so the
|
||||||
* codes here are part of the public contract - do not rename them.
|
* codes here are part of the public contract - do not rename them.
|
||||||
*
|
*
|
||||||
@@ -47,8 +47,8 @@ const deniedTypes = [
|
|||||||
* here plus a restart.
|
* here plus a restart.
|
||||||
*/
|
*/
|
||||||
const locales = [
|
const locales = [
|
||||||
{ code: 'en', name: 'English (en)', isDefault: true },
|
{ code: 'ar', name: 'Arabic (ar)', isDefault: true },
|
||||||
{ code: 'ar', name: 'Arabic (ar)' },
|
{ code: 'en', name: 'English (en)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const config = ({ env }: Core.Config.Shared.ConfigParams): Core.Config.Plugin => ({
|
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: {
|
i18n: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
config: {
|
config: {
|
||||||
defaultLocale: 'en',
|
defaultLocale: 'ar',
|
||||||
locales,
|
locales,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ export type LocaleDefinition = {
|
|||||||
* is that config file.
|
* is that config file.
|
||||||
*/
|
*/
|
||||||
export const FALLBACK_LOCALES: LocaleDefinition[] = [
|
export const FALLBACK_LOCALES: LocaleDefinition[] = [
|
||||||
{ code: 'en', name: 'English (en)', isDefault: true },
|
{ code: 'ar', name: 'Arabic (ar)', isDefault: true },
|
||||||
{ code: 'ar', name: 'Arabic (ar)' },
|
{ code: 'en', name: 'English (en)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** `type` of the users-permissions role that unauthenticated requests use. */
|
/** `type` of the users-permissions role that unauthenticated requests use. */
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ async function seedDemo(
|
|||||||
log(strapi, `${DEMO_PARTNERS.length} demo network partners`);
|
log(strapi, `${DEMO_PARTNERS.length} demo network partners`);
|
||||||
|
|
||||||
for (const item of DEMO_FAQS) {
|
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`);
|
log(strapi, `${DEMO_FAQS.length} demo FAQs`);
|
||||||
|
|
||||||
|
|||||||
+12
-11
@@ -7,12 +7,13 @@
|
|||||||
explicit `--demo --clean`.
|
explicit `--demo --clean`.
|
||||||
|
|
||||||
THE SHAPE OF A WRITE
|
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
|
translation - because every localised content type on this site ships in
|
||||||
both languages (CLAUDE.md rule 7). `upsert` writes the English document
|
both languages (CLAUDE.md rule 7) and Arabic is the CMS's default locale.
|
||||||
first, because that is the one carrying the identity (the slug), then
|
`upsert` writes the Arabic document first, because that is the one
|
||||||
writes the Arabic locale onto the SAME document, which is how Strapi 5
|
carrying the identity (the slug), then writes the English locale onto the
|
||||||
models a translation: one `documentId`, one row per locale.
|
SAME document, which is how Strapi 5 models a translation: one
|
||||||
|
`documentId`, one row per locale.
|
||||||
|
|
||||||
DRAFT AND PUBLISH
|
DRAFT AND PUBLISH
|
||||||
Content is written as a draft and then published explicitly. Publishing
|
Content is written as a draft and then published explicitly. Publishing
|
||||||
@@ -108,7 +109,7 @@ export async function upsert(
|
|||||||
seed ends up with duplicates. */
|
seed ends up with duplicates. */
|
||||||
const existing = await service.findFirst({
|
const existing = await service.findFirst({
|
||||||
...(find ? { filters: find } : {}),
|
...(find ? { filters: find } : {}),
|
||||||
locale: 'en',
|
locale: 'ar',
|
||||||
...draftFilter(strapi, uid),
|
...draftFilter(strapi, uid),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,16 +118,16 @@ export async function upsert(
|
|||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
documentId = existing.documentId;
|
documentId = existing.documentId;
|
||||||
await service.update({ documentId, locale: 'en', data: data.en });
|
await service.update({ documentId, locale: 'ar', data: data.ar });
|
||||||
} else {
|
} else {
|
||||||
const document = await service.create({ locale: 'en', data: data.en });
|
const document = await service.create({ locale: 'ar', data: data.ar });
|
||||||
documentId = document.documentId;
|
documentId = document.documentId;
|
||||||
created = true;
|
created = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* An update against a locale the document does not have yet creates that
|
/* An update against a locale the document does not have yet creates that
|
||||||
translation - there is no separate "add a locale" call. */
|
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)) {
|
if (usesDraft(strapi, uid)) {
|
||||||
await service.publish({ documentId, locale: '*' });
|
await service.publish({ documentId, locale: '*' });
|
||||||
@@ -144,7 +145,7 @@ export async function upsert(
|
|||||||
* 1. `delete({ documentId })` with no locale removes the DEFAULT locale
|
* 1. `delete({ documentId })` with no locale removes the DEFAULT locale
|
||||||
* only, not the document. It takes `locale: '*'` to remove the lot.
|
* only, not the document. It takes `locale: '*'` to remove the lot.
|
||||||
* 2. `findMany({ locale })` answers for one locale, so a document whose
|
* 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
|
* Both locales are searched and the documentIds pooled, which also
|
||||||
* sweeps up orphans an earlier, less careful run left.
|
* sweeps up orphans an earlier, less careful run left.
|
||||||
*/
|
*/
|
||||||
@@ -221,7 +222,7 @@ export async function readExisting(
|
|||||||
): Promise<SeedDocument | null> {
|
): Promise<SeedDocument | null> {
|
||||||
return documents(strapi, uid).findFirst({
|
return documents(strapi, uid).findFirst({
|
||||||
...(find ? { filters: find } : {}),
|
...(find ? { filters: find } : {}),
|
||||||
locale: 'en',
|
locale: 'ar',
|
||||||
...draftFilter(strapi, uid),
|
...draftFilter(strapi, uid),
|
||||||
populate,
|
populate,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export type Locale = (typeof LOCALES)[number];
|
|||||||
|
|
||||||
export type Direction = "ltr" | "rtl";
|
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>. */
|
/** Locale -> the value that belongs in <html dir>. */
|
||||||
const DIRECTION_BY_LOCALE: Record<Locale, Direction> = {
|
const DIRECTION_BY_LOCALE: Record<Locale, Direction> = {
|
||||||
|
|||||||
+10
-10
@@ -5,8 +5,8 @@
|
|||||||
plus {placeholder} interpolation. No i18next, no ICU MessageFormat - the
|
plus {placeholder} interpolation. No i18next, no ICU MessageFormat - the
|
||||||
catalogues hold UI chrome only, page prose comes from Strapi.
|
catalogues hold UI chrome only, page prose comes from Strapi.
|
||||||
|
|
||||||
English is the reference catalogue: `Messages` is derived from en.json, and
|
Arabic is the reference catalogue: `Messages` is derived from ar.json, and
|
||||||
ar.json is typechecked against it, so a key added on one side and forgotten
|
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.
|
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";
|
import { DEFAULT_LOCALE, type Locale } from "./config";
|
||||||
|
|
||||||
/** The shape of every catalogue. Derived from the English source of truth. */
|
/** The shape of every catalogue. Derived from the Arabic source of truth. */
|
||||||
export type Messages = typeof en;
|
export type Messages = typeof ar;
|
||||||
|
|
||||||
/** Anything that may be interpolated into a message. */
|
/** Anything that may be interpolated into a message. */
|
||||||
export type TranslationParams = Readonly<
|
export type TranslationParams = Readonly<
|
||||||
@@ -44,13 +44,13 @@ export type Translator = (
|
|||||||
params?: TranslationParams,
|
params?: TranslationParams,
|
||||||
) => string;
|
) => string;
|
||||||
|
|
||||||
/* The `Messages` annotation is the parity check: ar.json must supply every
|
/* The `Messages` annotation is the parity check: en.json must supply every
|
||||||
key en.json declares, with a string in every leaf position. */
|
key ar.json declares, with a string in every leaf position. */
|
||||||
const arabicMessages: Messages = ar;
|
const englishMessages: Messages = en;
|
||||||
|
|
||||||
const CATALOGUES: Readonly<Record<Locale, Messages>> = {
|
const CATALOGUES: Readonly<Record<Locale, Messages>> = {
|
||||||
en,
|
en: englishMessages,
|
||||||
ar: arabicMessages,
|
ar,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The whole catalogue for a locale - handy for spreading into a component. */
|
/** 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 "".
|
* missing it, and finally to the key itself so a page never renders "".
|
||||||
*/
|
*/
|
||||||
export function translate(
|
export function translate(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import type { MetaDescriptor } from "react-router";
|
import type { MetaDescriptor } from "react-router";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_LOCALE,
|
||||||
INTL_LOCALE,
|
INTL_LOCALE,
|
||||||
OG_LOCALE,
|
OG_LOCALE,
|
||||||
SUPPORTED_LOCALES,
|
SUPPORTED_LOCALES,
|
||||||
@@ -103,8 +104,8 @@ export interface LocaleAlternate {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The hreflang set for a page: one entry per locale plus `x-default`, which
|
* 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
|
* points at the default-locale URL because that is what a bare "/" resolves
|
||||||
* "/" negotiates towards.
|
* to when no locale can be negotiated.
|
||||||
*/
|
*/
|
||||||
export function alternateUrls(
|
export function alternateUrls(
|
||||||
siteUrl: string,
|
siteUrl: string,
|
||||||
@@ -121,9 +122,9 @@ export function alternateUrls(
|
|||||||
return [
|
return [
|
||||||
...alternates,
|
...alternates,
|
||||||
{
|
{
|
||||||
locale: "en",
|
locale: DEFAULT_LOCALE,
|
||||||
hrefLang: "x-default",
|
hrefLang: "x-default",
|
||||||
href: canonicalUrl(siteUrl, localizedPath(unprefixed, "en")),
|
href: canonicalUrl(siteUrl, localizedPath(unprefixed, DEFAULT_LOCALE)),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export interface AccordionProps {
|
|||||||
*/
|
*/
|
||||||
defaultOpenIndex?: number | null;
|
defaultOpenIndex?: number | null;
|
||||||
className?: string;
|
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 baseId = useId();
|
||||||
const initial =
|
const initial =
|
||||||
defaultOpenIndex === null ? null : (items[defaultOpenIndex]?.id ?? null);
|
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)]",
|
"border-b border-[var(--border-default)]",
|
||||||
"transition-colors duration-[var(--motion-duration-base)]",
|
"transition-colors duration-[var(--motion-duration-base)]",
|
||||||
"hover:bg-[var(--surface-muted)]",
|
"hover:bg-[var(--surface-muted)]",
|
||||||
|
itemClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -268,6 +268,39 @@
|
|||||||
animation-range: entry 0% entry 80%;
|
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
|
/* 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
|
before it travels, so the drift can never uncover an edge of the
|
||||||
frame. Chromium-only, like every timeline here: elsewhere the picture
|
frame. Chromium-only, like every timeline here: elsewhere the picture
|
||||||
@@ -306,6 +339,49 @@
|
|||||||
transform-origin: right center;
|
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 {
|
@keyframes sh-bar-grow {
|
||||||
from {
|
from {
|
||||||
transform: scaleX(0);
|
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
|
2. The route's `meta` appends `detailAlternateDescriptors(...)` to whatever
|
||||||
`buildMeta` produced, which emits <link rel="canonical"> plus one
|
`buildMeta` produced, which emits <link rel="canonical"> plus one
|
||||||
<link rel="alternate" hreflang> per locale that actually has a slug, and
|
<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
|
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
|
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
|
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 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 { sectionPath, type RouteSlugKey } from "~/i18n/paths";
|
||||||
import { canonicalUrl } from "~/lib/seo";
|
import { canonicalUrl } from "~/lib/seo";
|
||||||
|
|
||||||
@@ -160,8 +160,11 @@ export function detailAlternateDescriptors({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// x-default is the English URL, because bare "/" negotiates towards English.
|
// x-default is the default-locale URL, because bare "/" negotiates towards
|
||||||
const fallback = paths.find((entry) => entry.locale === "en");
|
// 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) {
|
if (fallback) {
|
||||||
descriptors.push({
|
descriptors.push({
|
||||||
tagName: "link",
|
tagName: "link",
|
||||||
|
|||||||
@@ -36,13 +36,16 @@
|
|||||||
|
|
||||||
MOTION: every animation in the draft is here, as classes from
|
MOTION: every animation in the draft is here, as classes from
|
||||||
_page-kit/motion.css - the headline wipe, the hero picture's scroll-driven
|
_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
|
drift, each item tasting in as it scrolls into view (the edge tiles of a row
|
||||||
deliberately dropped is the coloured glow behind a hovered button or card.
|
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
|
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-).
|
is why every inline edge below is logical (ps-/pe-/start-/end-).
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import { Link, useRouteLoaderData } from "react-router";
|
import { Link, useRouteLoaderData } from "react-router";
|
||||||
|
|
||||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
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 type { Route } from "./+types/home";
|
||||||
import { Accordion } from "./_page-kit/accordion";
|
import { Accordion } from "./_page-kit/accordion";
|
||||||
|
import { enableReveals } from "./_page-kit/reveal";
|
||||||
import {
|
import {
|
||||||
isImageGalleryBlock,
|
isImageGalleryBlock,
|
||||||
type CmsFaq,
|
type CmsFaq,
|
||||||
@@ -365,6 +369,25 @@ function StatusPill({ label }: { label: string }) {
|
|||||||
* vertically centred on the middle. One or two pictures fall back to the
|
* 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.
|
* 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[] }) {
|
function GalleryShots({ images }: { images: readonly ResolvedImage[] }) {
|
||||||
if (images.length === 0) return null;
|
if (images.length === 0) return null;
|
||||||
|
|
||||||
@@ -380,7 +403,10 @@ function GalleryShots({ images }: { images: readonly ResolvedImage[] }) {
|
|||||||
the draft's 60px. */
|
the draft's 60px. */
|
||||||
<ul className="grid list-none grid-cols-1 gap-3xl lg:grid-cols-[427fr_605fr_426fr] lg:items-center">
|
<ul className="grid list-none grid-cols-1 gap-3xl lg:grid-cols-[427fr_605fr_426fr] lg:items-center">
|
||||||
{images.map((image, index) => (
|
{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">
|
<figure className="flex flex-col gap-xs">
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
@@ -409,8 +435,11 @@ function GalleryShots({ images }: { images: readonly ResolvedImage[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{images.map((image) => (
|
{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">
|
<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)]">
|
<div className="sh-lift sh-lift--grow rounded-control border border-[var(--border-subtle)] hover:border-[var(--border-accent)]">
|
||||||
<img
|
<img
|
||||||
@@ -521,12 +550,20 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||||||
exactly as the footer's badge does: same root cookie, same emblem. */
|
exactly as the footer's badge does: same root cookie, same emblem. */
|
||||||
const root = useRouteLoaderData<{ theme?: AppTheme }>("root");
|
const root = useRouteLoaderData<{ theme?: AppTheme }>("root");
|
||||||
const theme: AppTheme = root?.theme ?? DEFAULT_THEME;
|
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
|
/* The three pillars are the About page's approved copy - one source, so the
|
||||||
two pages can never drift apart. */
|
two pages can never drift apart. */
|
||||||
const pillars = pageCopy(locale).about.pillars;
|
const pillars = pageCopy(locale).about.pillars;
|
||||||
|
|
||||||
const headline = hero.headline ?? copy.heroHeadline;
|
const headline = hero.headline ?? copy.heroHeadline;
|
||||||
const lead = hero.subheading ?? copy.heroLead;
|
// const lead = hero.subheading ?? copy.heroLead;
|
||||||
const consortiumHeading = loaderData.partnersHeadline ?? copy.consortiumHeading;
|
const consortiumHeading = loaderData.partnersHeadline ?? copy.consortiumHeading;
|
||||||
|
|
||||||
/* The frame draws one accent CTA and one white one. Editor-supplied CTAs go
|
/* 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
|
<PageHero
|
||||||
className="bg-[var(--surface-sunken)] [--layout-hero-top:clamp(2.5rem,6vw,4.5rem)]"
|
className="bg-[var(--surface-sunken)] [--layout-hero-top:clamp(2.5rem,6vw,4.5rem)]"
|
||||||
title={headline}
|
title={headline}
|
||||||
lead={lead}
|
lead={""}
|
||||||
leadWithActions
|
leadWithActions
|
||||||
{...(hero.eyebrow ? { statusPill: <StatusPill label={hero.eyebrow} /> } : {})}
|
{...(hero.eyebrow ? { statusPill: <StatusPill label={hero.eyebrow} /> } : {})}
|
||||||
ambient={[
|
ambient={[
|
||||||
@@ -651,12 +688,18 @@ reveal
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
|
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
|
||||||
{pillars.map((pillar) => (
|
{pillars.map((pillar, index) => (
|
||||||
<li
|
<li
|
||||||
key={pillar.title}
|
key={pillar.title}
|
||||||
/* `sh-rule-top` draws the draft's accent rule across the top
|
/* `sh-reveal--in` adds the draft's rise-and-scale; the edge
|
||||||
of the tile as the pointer arrives. */
|
tiles of the row slide in from their own side instead.
|
||||||
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-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>
|
<h3 className="heading-h3 text-[var(--text-primary)]">{pillar.title}</h3>
|
||||||
<p className="body-md text-[var(--text-secondary)]">
|
<p className="body-md text-[var(--text-secondary)]">
|
||||||
@@ -687,11 +730,15 @@ reveal
|
|||||||
|
|
||||||
{news.length > 0 ? (
|
{news.length > 0 ? (
|
||||||
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
<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
|
<Card.Content
|
||||||
key={item.id}
|
key={item.id}
|
||||||
as="li"
|
as="li"
|
||||||
className="sh-reveal sh-reveal--in"
|
className={cx(
|
||||||
|
"sh-reveal sh-reveal--in",
|
||||||
|
revealEdge(index, news.length),
|
||||||
|
revealStep(index),
|
||||||
|
)}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
title={item.title}
|
title={item.title}
|
||||||
actionLabel={t("actions.readMore")}
|
actionLabel={t("actions.readMore")}
|
||||||
@@ -744,7 +791,7 @@ reveal
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{faqs.length > 0 ? (
|
{faqs.length > 0 ? (
|
||||||
<Accordion items={faqs} className="sh-reveal" />
|
<Accordion items={faqs} itemClassName="sh-reveal" />
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message={copy.faqEmpty} />
|
<EmptyState message={copy.faqEmpty} />
|
||||||
)}
|
)}
|
||||||
@@ -770,10 +817,14 @@ reveal
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
|
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
|
||||||
{copy.consortium.map((member) => (
|
{copy.consortium.map((member, index) => (
|
||||||
<li
|
<li
|
||||||
key={member.name}
|
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)]">
|
<span className="rounded-chip bg-[var(--surface-accent-subtle)] px-xs py-2xs label-md uppercase text-[var(--text-accent)]">
|
||||||
{member.role}
|
{member.role}
|
||||||
|
|||||||
Reference in New Issue
Block a user