Rebuild the site to the Claude Design drafts
Carries the 11 page drafts plus the shared SiteNav and SiteFooter onto the project's own design system: tier-2 tokens, the published type roles, logical properties throughout, and both locales. Surfaces - The page stands on #0B0B0A with #1D1D1B bands lifted off it, as the drafts do; the body previously painted the band colour, so every band was a step too light. - A card is drawn one step off whatever it stands on (#1D1D1B on the ground, #141413 inside a band). Components paint --surface-card and a band re-points it for its subtree, so a card can never come out the same colour as its band. Shell - Nav: translucent ground with a blur, chevrons on group triggers, an accent rule under the current section, the drafts' compact dropdown card, and an EN | AR toggle in place of the globe menu. - Footer: the drafts' four-column grid, one-line brand text, Facebook as a bordered button, the EU lockup, the implemented-by row and the funding disclaimer. Pages - Home: hero with counting figures, launch band, three areas of change, newsroom, FAQ accordion and the consortium band. - The nine interior pages rebuilt and then audited section by section against their drafts. - Applications: the three-step application form now posts real submissions to the Strapi `application` type, validated server-side and working without JavaScript. Motion - Every animation in the drafts, as classes in _page-kit/motion.css: staggered entrances, scroll-driven reveals, the drifting washes (each page carries its own geometry), the sheen, the counting figures and the accordion. The drafts' coloured hover glow is deliberately not shipped. CMS - New `faq` collection type behind the home page's accordion. - `role` added to the consortium member component so an editor can supply the chip and the deliverable separately. Content - No invented figure, date, name or commitment ships: the five-working-day reply, the placeholder email and Facebook handle, the sample news, events, publications and FAQs, the invented application rules and the TBD counters are all withheld pending real content. Figures that survived (3 consortium partners, 4 areas of work, 16 human security sub-projects, Dec 2025 - Dec 2027) are verified against docs/ and the bundled draft copy. Verified: pnpm typecheck (web + CMS) and pnpm --filter web build pass; all 20 page/locale URLs render 200 through the built SSR handler, including with the CMS unreachable; no raw hex or physical-direction utility in components or routes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+530
-231
@@ -1,38 +1,56 @@
|
||||
/* ============================================================================
|
||||
HOME - /en and /ar
|
||||
----------------------------------------------------------------------------
|
||||
Figma frame 253:4799 ("Home-en"). The frame is four bands:
|
||||
The redesigned home page (Claude Design "Home", second drop), carried onto
|
||||
the project's own design system. Six bands, alternating the two dark
|
||||
surfaces the way the draft alternates #0B0B0A and #1D1D1B:
|
||||
|
||||
Hero header (253:4800) the SHARP-ER lockup, the headline, the CTA
|
||||
pair and the wide washed picture. The frame's
|
||||
subheading node is hidden, so the hero carries
|
||||
no lead unless the CMS supplies one.
|
||||
About the Programme 253:4835, hidden="true" in the frame. It is
|
||||
the only place figures appear, so the band
|
||||
renders ONLY once the CMS has real numbers -
|
||||
there is no placeholder standing in for it.
|
||||
Offical launch (361:10853) "The Journey of SHARP-ER Launch": one centred
|
||||
lead with four bold runs, a three-up photo
|
||||
strip that bleeds past the page and is
|
||||
clipped, and three plain centred captions.
|
||||
Partners (393:14754) "Meet Our Partners", start-aligned, over a
|
||||
grid of logo-and-name tiles.
|
||||
Footer owned by the app shell, not by this module
|
||||
Hero EU badge, an optional status pill, the headline, the lead,
|
||||
the CTA pair, the wide picture, the brand stripe and the
|
||||
figures row. Two slow washes drift behind it.
|
||||
Launch "An important start for the project": the launch lead and
|
||||
three pictures from the CMS gallery.
|
||||
Work "Three areas of change" - the three approved pillars, with
|
||||
a link through to What We Do.
|
||||
Newsroom three cards from the newsroom, newest first.
|
||||
FAQs a sticky heading column beside the accordion.
|
||||
Consortium the three members, then the civil society network logos.
|
||||
|
||||
There is no Arabic frame in the file - only the navigation and the footer
|
||||
are drawn in Arabic - so /ar is this same DOM under `dir="rtl"`, which is
|
||||
why every inline edge below is logical (ps-/pe-/start-/end-) and never
|
||||
left/right.
|
||||
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
|
||||
--------------------------------------------
|
||||
* The draft's FAQ column promises a reply "within five working days". That
|
||||
commitment appears in no approved document, so it is not published
|
||||
(CLAUDE.md rule 5). Everything else in the draft is verifiable: the four
|
||||
figures, the ministries at the launch and the three pillars are all in
|
||||
docs/ or in the bundled draft copy.
|
||||
* Every list - news, questions, partner logos, launch pictures - is CMS
|
||||
data, never the draft's sample entries. Each band shows its localised
|
||||
empty state instead of inventing content.
|
||||
* The draft's own navigation and footer are ignored: the site ships
|
||||
<Navigation /> and <Footer /> from the app shell.
|
||||
|
||||
Copy is the approved draft in both languages; the pictures, the figures and
|
||||
the partner list come from Strapi and degrade to a localised empty state
|
||||
while it is empty.
|
||||
MOTION: every animation in the draft is here, as classes from
|
||||
_page-kit/motion.css, plus the figures counting up on arrival. 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, useLayoutEffect, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||
import { createTranslator, getMessages, sectionPath } from "~/i18n";
|
||||
import { Card } from "~/components/ui/Card";
|
||||
import { EuBadge } from "~/components/ui/EuBadge";
|
||||
import {
|
||||
createTranslator,
|
||||
formatDate,
|
||||
getMessages,
|
||||
sectionPath,
|
||||
type Locale,
|
||||
} from "~/i18n";
|
||||
import {
|
||||
buildMeta,
|
||||
buildOrganizationJsonLd,
|
||||
@@ -41,9 +59,12 @@ import {
|
||||
import { safeHref } from "~/lib/safe-href";
|
||||
|
||||
import type { Route } from "./+types/home";
|
||||
import { Accordion } from "./_page-kit/accordion";
|
||||
import {
|
||||
isImageGalleryBlock,
|
||||
type CmsFaq,
|
||||
type CmsHomePage,
|
||||
type CmsNewsArticle,
|
||||
type CmsPartner,
|
||||
} from "./_page-kit/cms-types";
|
||||
import {
|
||||
@@ -58,30 +79,50 @@ import { pageCopy } from "./_page-kit/copy";
|
||||
import { routeLocale } from "./_page-kit/locale";
|
||||
import {
|
||||
Container,
|
||||
cx,
|
||||
EmptyState,
|
||||
PageHero,
|
||||
PageSection,
|
||||
SectionHeader,
|
||||
StatStrip,
|
||||
} from "./_page-kit/sections";
|
||||
|
||||
/** Partner logos and CSO tiles - a wide but finite grid. */
|
||||
const PARTNER_PAGE_SIZE = 36;
|
||||
import "./_page-kit/motion.css";
|
||||
|
||||
/**
|
||||
* The mark the hero draws above the headline (figma 253:4800). The frame paints
|
||||
* "SHARP" only - 272.58 x 57.65 of ink - not the full SHARP-ER lockup the
|
||||
* navigation carries, so this is the SHARP wordmark on its own, as vector so it
|
||||
* stays crisp at any size. `alt` is empty because the navigation's brand link
|
||||
* already carries the project's accessible name.
|
||||
*/
|
||||
const HERO_WORDMARK = {
|
||||
src: "/assets/sharp-wordmark-inverse.svg",
|
||||
alt: "",
|
||||
width: 272,
|
||||
height: 57,
|
||||
drawnWidth: 273,
|
||||
} as const;
|
||||
/** The network logo wall - a wide but finite grid. */
|
||||
const PARTNER_PAGE_SIZE = 36;
|
||||
/** The draft's newsroom band is a straight three-up row. */
|
||||
const NEWS_PAGE_SIZE = 3;
|
||||
/** More than a dozen questions is a page of its own, not a home page band. */
|
||||
const FAQ_PAGE_SIZE = 12;
|
||||
/** The draft's launch band shows three pictures. */
|
||||
const LAUNCH_SHOTS = 3;
|
||||
|
||||
/** A newsroom card, already localised and safe to serialise to the client. */
|
||||
interface NewsCard {
|
||||
id: string;
|
||||
title: string;
|
||||
href: string;
|
||||
excerpt: string | null;
|
||||
dateLabel: string | null;
|
||||
organisation: string | null;
|
||||
image: { src: string; alt: string } | null;
|
||||
}
|
||||
|
||||
function toNewsCard(article: CmsNewsArticle, locale: Locale): NewsCard {
|
||||
const image = toImage(article.coverImage, "small");
|
||||
|
||||
return {
|
||||
id: article.documentId,
|
||||
title: article.title,
|
||||
href: `${sectionPath("news", locale)}/${article.slug}`,
|
||||
excerpt: article.excerpt ?? null,
|
||||
/* The CMS holds one date for both languages; the reader's locale decides
|
||||
how it is written out. */
|
||||
dateLabel: article.publishDate ? formatDate(article.publishDate, locale) : null,
|
||||
organisation: article.authorOrganisation?.name ?? null,
|
||||
image: image ? { src: image.src, alt: image.alt } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const locale = routeLocale(params);
|
||||
@@ -93,18 +134,19 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
stats: true,
|
||||
// Strapi 5 populates a dynamic zone per component with `on`.
|
||||
blocks: { on: { "blocks.image-gallery": { populate: { images: true } } } },
|
||||
featuredNews: { populate: { coverImage: true, authorOrganisation: true } },
|
||||
partners: { populate: { logo: true, logoDark: true } },
|
||||
seo: { populate: { ogImage: true } },
|
||||
},
|
||||
});
|
||||
|
||||
/* The curated relation wins; otherwise show every partner the CMS knows, so
|
||||
the grid fills in as soon as partners exist - before anyone remembers to
|
||||
the wall fills in as soon as partners exist - before anyone remembers to
|
||||
curate the home page itself. */
|
||||
const curated = list(page?.partners);
|
||||
const curatedPartners = list(page?.partners);
|
||||
const partners =
|
||||
curated.length > 0
|
||||
? curated
|
||||
curatedPartners.length > 0
|
||||
? curatedPartners
|
||||
: await loadMany<CmsPartner>("partners", {
|
||||
locale,
|
||||
sort: ["name:asc"],
|
||||
@@ -113,6 +155,25 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
pagination: { pageSize: PARTNER_PAGE_SIZE },
|
||||
});
|
||||
|
||||
/* Same rule for the newsroom: an editor's picks, else simply the newest
|
||||
articles, so the band is never empty while the newsroom has entries. */
|
||||
const curatedNews = list(page?.featuredNews);
|
||||
const news =
|
||||
curatedNews.length > 0
|
||||
? curatedNews
|
||||
: await loadMany<CmsNewsArticle>("news-articles", {
|
||||
locale,
|
||||
sort: ["publishDate:desc", "title:asc"],
|
||||
populate: { coverImage: true, authorOrganisation: true },
|
||||
pagination: { pageSize: NEWS_PAGE_SIZE },
|
||||
});
|
||||
|
||||
const faqs = await loadMany<CmsFaq>("faqs", {
|
||||
locale,
|
||||
sort: ["order:asc", "createdAt:asc"],
|
||||
pagination: { pageSize: FAQ_PAGE_SIZE },
|
||||
});
|
||||
|
||||
const gallery = list(page?.blocks)
|
||||
.filter(isImageGalleryBlock)
|
||||
.flatMap((block) => list(block.images))
|
||||
@@ -132,13 +193,20 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
variant: cta.variant ?? "primary",
|
||||
})),
|
||||
},
|
||||
/* Editor-owned figures win; the bundled four stand in until then. */
|
||||
stats: list(page?.stats).map((stat) => ({
|
||||
value: stat.value,
|
||||
label: stat.label,
|
||||
})),
|
||||
gallery,
|
||||
gallery: gallery.slice(0, LAUNCH_SHOTS),
|
||||
partnersHeadline: page?.partnersHeadline ?? null,
|
||||
partners: partners.map(toPartner),
|
||||
news: news.slice(0, NEWS_PAGE_SIZE).map((article) => toNewsCard(article, locale)),
|
||||
faqs: faqs.map((faq) => ({
|
||||
id: faq.documentId,
|
||||
question: faq.question,
|
||||
answer: faq.answer,
|
||||
})),
|
||||
seo: {
|
||||
title: page?.seo?.metaTitle ?? null,
|
||||
description: page?.seo?.metaDescription ?? null,
|
||||
@@ -171,6 +239,10 @@ export function meta({ loaderData, matches }: Route.MetaArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
SMALL PIECES
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
interface HeroCtaProps {
|
||||
href: string;
|
||||
label: string;
|
||||
@@ -178,12 +250,13 @@ interface HeroCtaProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* A hero call to action. Figma paints the secondary one as a white fill with a
|
||||
* black stroke and a dark label, which is the secondary variant on the LIGHT
|
||||
* surface - so the surface is flipped on the control itself. It has to be the
|
||||
* control and not a wrapper: `[data-surface]` paints a background in the base
|
||||
* layer, and a wrapping element would draw a square white box behind the
|
||||
* button's rounded corners.
|
||||
* A hero call to action.
|
||||
*
|
||||
* Both controls are the project's Button, which now carries the Claude Design
|
||||
* drafts' own treatment: the accent fill for the primary, the outline ring for
|
||||
* the secondary, and a 3px lift under the pointer. The secondary is no longer
|
||||
* flipped onto the light surface - the drafts draw it as an outline on the
|
||||
* dark band, not as a white fill.
|
||||
*/
|
||||
function HeroCta({ href, label, variant }: HeroCtaProps) {
|
||||
return (
|
||||
@@ -193,7 +266,6 @@ function HeroCta({ href, label, variant }: HeroCtaProps) {
|
||||
prefetch="intent"
|
||||
size="lg"
|
||||
variant={variant}
|
||||
{...(variant === "secondary" ? { "data-surface": "light" } : {})}
|
||||
iconEnd={variant === "primary" ? <ArrowIcon /> : undefined}
|
||||
>
|
||||
{label}
|
||||
@@ -202,70 +274,160 @@ function HeroCta({ href, label, variant }: HeroCtaProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The three-up strip under the launch lead (figma 361:11115). The row is drawn
|
||||
* 1849px wide against a 1440px page, so it deliberately overflows the page and
|
||||
* is clipped by the band - the widths below are the designed proportions of the
|
||||
* 1440 artboard (500 / 729 / 500) carried over as viewport fractions, which
|
||||
* reproduces the overhang at any width. The centre picture is the emphasised
|
||||
* one; the flanks sit at half strength and are dropped on small screens, where
|
||||
* three clipped pictures would be unreadable.
|
||||
* The hero's status line. The draft hard-codes "Now live · Expression of
|
||||
* Interest open"; here the words are the CMS hero eyebrow, so the pill only
|
||||
* ever announces a state an editor has actually written - and the hero simply
|
||||
* has no pill until then. The dot pulses; the text does not move.
|
||||
*/
|
||||
function LaunchStrip({ images }: { images: readonly ResolvedImage[] }) {
|
||||
const first = images[0];
|
||||
if (!first) return null;
|
||||
function StatusPill({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-xs rounded-pill border border-[var(--border-subtle)] bg-[var(--surface-raised)] py-2xs ps-sm pe-md">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="sh-pulse size-[7px] shrink-0 rounded-pill bg-[var(--surface-accent)]"
|
||||
/>
|
||||
<span className="label-md uppercase text-[var(--text-secondary)]">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* The flanks only exist as a trio. With one or two pictures the emphasised
|
||||
one stays centred and the row simply has no wings, rather than throwing
|
||||
the centre off-axis or hiding an uploaded picture below `lg`. */
|
||||
const complete = images.length >= 3;
|
||||
const centre = complete ? images[1]! : first;
|
||||
const flanks = complete
|
||||
? { start: images[0]!, end: images[2]! }
|
||||
: { start: undefined, end: undefined };
|
||||
/* `useLayoutEffect` would warn during SSR, and the count only exists in a
|
||||
browser, so the effect is picked per environment. */
|
||||
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||
|
||||
/**
|
||||
* A figure that counts up to its value on arrival, as the draft does.
|
||||
*
|
||||
* The server renders the FINAL value, so a reader without JavaScript - and a
|
||||
* search engine - sees the real figure. In a browser the count is reset before
|
||||
* the first paint (layout effect, not a plain effect), so the number never
|
||||
* flashes its end state first. A value the CMS wrote as words rather than
|
||||
* digits is simply printed.
|
||||
*/
|
||||
function CountUpValue({ value }: { value: string }) {
|
||||
const parsed = /^(\d[\d,]*)([\s\S]*)$/.exec(value);
|
||||
const target = parsed ? Number(parsed[1]!.replace(/,/g, "")) : null;
|
||||
const suffix = parsed ? parsed[2]! : "";
|
||||
const [shown, setShown] = useState<number | null>(target);
|
||||
|
||||
useBrowserLayoutEffect(() => {
|
||||
if (target === null) return;
|
||||
/* app.css collapses animation, but this one is script-driven, so the
|
||||
preference has to be honoured here as well. */
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
let frame = 0;
|
||||
const started = performance.now();
|
||||
const duration = 1500;
|
||||
|
||||
setShown(0);
|
||||
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min(1, (now - started) / duration);
|
||||
/* Ease out cubic - the draft's own curve. */
|
||||
setShown(Math.round(target * (1 - Math.pow(1 - progress, 3))));
|
||||
if (progress < 1) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [target]);
|
||||
|
||||
if (target === null) return <>{value}</>;
|
||||
|
||||
return (
|
||||
<ul className="flex list-none items-center justify-center gap-3xl">
|
||||
{flanks.start ? (
|
||||
<li className="hidden shrink-0 lg:block">
|
||||
<img
|
||||
src={flanks.start.src}
|
||||
alt={flanks.start.alt}
|
||||
loading="lazy"
|
||||
className="aspect-[500/268] w-[34.7vw] object-cover opacity-50"
|
||||
/>
|
||||
</li>
|
||||
) : null}
|
||||
<>
|
||||
{shown}
|
||||
{suffix}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
<li className="shrink-0">
|
||||
<img
|
||||
src={centre.src}
|
||||
alt={centre.alt}
|
||||
loading="lazy"
|
||||
className="aspect-[729/374] w-[86vw] object-cover lg:w-[50.6vw]"
|
||||
/>
|
||||
</li>
|
||||
/**
|
||||
* The figures row that closes the hero: four cells divided by hairlines. The
|
||||
* grid's own background paints the 1px gaps, which is how the draft draws the
|
||||
* dividers without a border on each cell.
|
||||
*/
|
||||
function StatsRow({ stats }: { stats: readonly { value: string; label: string }[] }) {
|
||||
if (stats.length === 0) return null;
|
||||
|
||||
{flanks.end ? (
|
||||
<li className="hidden shrink-0 lg:block">
|
||||
<img
|
||||
src={flanks.end.src}
|
||||
alt={flanks.end.alt}
|
||||
loading="lazy"
|
||||
className="aspect-[500/267] w-[34.7vw] object-cover opacity-50"
|
||||
/>
|
||||
return (
|
||||
<dl className="grid grid-cols-1 gap-px border-y border-[var(--border-subtle)] bg-[var(--border-subtle)] sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
/* Source order is term-then-description, as the spec requires; the
|
||||
reversed column paints the figure above its label without repeating
|
||||
the label for a screen reader. */
|
||||
<div
|
||||
key={`${stat.value}-${stat.label}`}
|
||||
className="flex flex-col-reverse gap-2xs bg-[var(--surface-sunken)] px-lg py-xl text-start transition-colors duration-[var(--motion-duration-base)] hover:bg-[var(--surface-raised)]"
|
||||
>
|
||||
<dt className="label-md uppercase text-[var(--text-tertiary)]">{stat.label}</dt>
|
||||
<dd className="heading-h1 tabular-nums text-[var(--text-primary)]">
|
||||
<CountUpValue value={stat.value} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The launch band's three pictures. The CMS alt text doubles as the visible
|
||||
* caption - so the picture is described once, in the caption, and the <img>
|
||||
* itself is marked decorative rather than having a screen reader read the same
|
||||
* sentence twice.
|
||||
*/
|
||||
function LaunchShots({ images }: { images: readonly ResolvedImage[] }) {
|
||||
if (images.length === 0) return null;
|
||||
|
||||
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">
|
||||
<figure className="flex flex-col gap-xs">
|
||||
<img
|
||||
src={image.src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="sh-lift h-[15rem] w-full rounded-control border border-[var(--border-subtle)] object-cover hover:border-[var(--border-accent)]"
|
||||
/>
|
||||
{image.alt ? (
|
||||
<figcaption className="body-sm-bold text-[var(--text-secondary)]">
|
||||
{image.alt}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
</li>
|
||||
) : null}
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** The "see the whole section" link the draft puts opposite a band heading. */
|
||||
function BandLink({ to, label }: { to: string; label: string }) {
|
||||
return (
|
||||
<Button as={Link} to={to} prefetch="intent" variant="ghost" iconEnd={<ArrowIcon />}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
THE PAGE
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
const { locale, hero, stats, gallery, partners } = loaderData;
|
||||
const { locale, hero, stats, gallery, partners, news, faqs } = loaderData;
|
||||
const t = createTranslator(locale);
|
||||
const copy = pageCopy(locale).home;
|
||||
/* 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 partnersHeadline = loaderData.partnersHeadline ?? copy.partnersHeading;
|
||||
const lead = hero.subheading ?? copy.heroLead;
|
||||
const figures = stats.length > 0 ? stats : copy.stats;
|
||||
const consortiumHeading = loaderData.partnersHeadline ?? copy.consortiumHeading;
|
||||
|
||||
/* The frame draws one accent CTA and one white one. Editor-supplied CTAs go
|
||||
through the same component as the fallback pair, so a `secondary` CTA
|
||||
@@ -279,165 +441,302 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
}))
|
||||
: [
|
||||
{
|
||||
href: sectionPath("about", locale),
|
||||
label: t("actions.discoverSharper"),
|
||||
href: sectionPath("applications", locale),
|
||||
label: copy.heroCtaPrimary,
|
||||
variant: "primary",
|
||||
},
|
||||
{
|
||||
href: sectionPath("whatWeDo", locale),
|
||||
label: t("actions.exploreOurWork"),
|
||||
href: sectionPath("about", locale),
|
||||
label: copy.heroCtaSecondary,
|
||||
variant: "secondary",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* `lead` is passed only when the CMS supplies a subheading: the frame's
|
||||
own subheading node is hidden, so nothing stands under the headline
|
||||
unless an editor deliberately writes one. */}
|
||||
<PageHero
|
||||
title={headline}
|
||||
{...(hero.eyebrow ? { eyebrow: hero.eyebrow } : {})}
|
||||
{...(hero.subheading ? { lead: hero.subheading } : {})}
|
||||
wordmark={HERO_WORDMARK}
|
||||
{...(hero.media ? { media: hero.media } : {})}
|
||||
mediaTreatment="banner"
|
||||
actions={
|
||||
heroCtas.map((cta) => (
|
||||
<HeroCta key={`${cta.href}-${cta.label}`} {...cta} />
|
||||
))
|
||||
}
|
||||
{/* The reading-progress rule. Decorative, pointer-transparent, and
|
||||
painted only where scroll timelines exist (motion.css). */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="sh-progress pointer-events-none fixed inset-x-0 top-0 z-[var(--layer-dropdown)] h-[3px]"
|
||||
/>
|
||||
|
||||
{/* FIGURES - 253:4835 is hidden in the frame and every figure in the
|
||||
draft is marked TBD, so the band exists only once the CMS has real
|
||||
numbers. Nothing is rendered in its place. */}
|
||||
{stats.length > 0 ? (
|
||||
<PageSection aria-labelledby="home-stats-heading">
|
||||
<div className="flex flex-col gap-3xl">
|
||||
<SectionHeader
|
||||
id="home-stats-heading"
|
||||
title={copy.statsHeading}
|
||||
size="display"
|
||||
/>
|
||||
<StatStrip stats={stats} />
|
||||
</div>
|
||||
</PageSection>
|
||||
) : null}
|
||||
<PageHero
|
||||
className="bg-[var(--surface-sunken)] [--layout-hero-top:clamp(2.5rem,6vw,4.5rem)] [--layout-hero-bottom:var(--spacing-2xl)]"
|
||||
title={headline}
|
||||
lead={lead}
|
||||
leadWithActions
|
||||
{...(hero.eyebrow ? { statusPill: <StatusPill label={hero.eyebrow} /> } : {})}
|
||||
ambient={[
|
||||
{ top: -220, from: "start", offset: "8%", width: 780, height: 620, durationSeconds: 18 },
|
||||
{
|
||||
top: 120,
|
||||
from: "end",
|
||||
offset: "-160px",
|
||||
width: 620,
|
||||
height: 520,
|
||||
tone: "danger",
|
||||
durationSeconds: 24,
|
||||
reverse: true,
|
||||
},
|
||||
]}
|
||||
reveal
|
||||
/* EU visibility sits at the top of the hero (the draft's badge). The
|
||||
emblem is the dark-surface export because the hero sits on the
|
||||
page's own dark surface. `emblemAlt` is left at its empty default:
|
||||
the statement is real text beside the emblem, so naming the image
|
||||
would read the attribution out twice. */
|
||||
funderBadge={
|
||||
<EuBadge
|
||||
size="sm"
|
||||
statement={t("site.euStatement")}
|
||||
locale={locale}
|
||||
surface="dark"
|
||||
className="self-start"
|
||||
/>
|
||||
}
|
||||
{...(hero.media ? { media: hero.media } : {})}
|
||||
mediaTreatment="banner"
|
||||
actions={heroCtas.map((cta) => (
|
||||
<HeroCta key={`${cta.href}-${cta.label}`} {...cta} />
|
||||
))}
|
||||
below={<StatsRow stats={figures} />}
|
||||
/>
|
||||
|
||||
{/* THE LAUNCH STORY - the band is `bleed` so the photo strip can run past
|
||||
the content column, and it clips what runs past the page. */}
|
||||
{/* THE LAUNCH */}
|
||||
<PageSection
|
||||
aria-labelledby="home-launch-heading"
|
||||
bleed
|
||||
className="overflow-hidden"
|
||||
band
|
||||
>
|
||||
<div className="flex flex-col gap-3xl">
|
||||
<Container>
|
||||
<div className="flex flex-col items-center gap-lg text-center">
|
||||
<SectionHeader
|
||||
id="home-launch-heading"
|
||||
title={copy.launchHeading}
|
||||
size="display"
|
||||
align="center"
|
||||
/>
|
||||
<div className="sh-reveal mx-auto flex max-w-[55rem] flex-col items-center gap-lg text-center">
|
||||
<SectionHeader
|
||||
id="home-launch-heading"
|
||||
eyebrow={copy.launchEyebrow}
|
||||
title={copy.launchHeading}
|
||||
size="display"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
{/* One paragraph with four bold runs, as the frame sets it. The
|
||||
designed 20px has no published type role, so it takes the
|
||||
nearest one, Body/Large - whose 28px leading is the frame's. */}
|
||||
<p className="body-lg text-[var(--text-primary)]">
|
||||
{copy.launchLead.map((run, index) =>
|
||||
typeof run === "string" ? (
|
||||
<span key={index}>{run}</span>
|
||||
) : (
|
||||
<strong key={index} className="body-lg-bold">
|
||||
{run.em}
|
||||
</strong>
|
||||
),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
<p className="body-lg text-[var(--text-secondary)]">{copy.launchLead}</p>
|
||||
</div>
|
||||
|
||||
{gallery.length > 0 ? <LaunchStrip images={gallery} /> : null}
|
||||
|
||||
<Container>
|
||||
{/* Figma prints these three captions with no heading of their own;
|
||||
the heading exists for the outline and for screen readers. */}
|
||||
<h3 id="home-mechanisms-heading" className="sr-only">
|
||||
{copy.mechanismsHeading}
|
||||
</h3>
|
||||
|
||||
<ul
|
||||
aria-labelledby="home-mechanisms-heading"
|
||||
className="grid list-none grid-cols-1 gap-lg text-center sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{copy.mechanisms.map((mechanism) => (
|
||||
<li
|
||||
key={mechanism}
|
||||
className="mx-auto max-w-[21.875rem] heading-h2 text-[var(--text-primary)]"
|
||||
>
|
||||
{mechanism}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Container>
|
||||
<LaunchShots images={gallery} />
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* PARTNERS */}
|
||||
<PageSection aria-labelledby="home-partners-heading">
|
||||
<div className="flex flex-col gap-3xl">
|
||||
<SectionHeader
|
||||
id="home-partners-heading"
|
||||
title={partnersHeadline}
|
||||
size="display"
|
||||
/>
|
||||
{/* THREE AREAS OF CHANGE */}
|
||||
<PageSection
|
||||
aria-labelledby="home-work-heading"
|
||||
className="bg-[var(--surface-sunken)]"
|
||||
>
|
||||
<div className="flex flex-col gap-2xl">
|
||||
<div className="sh-reveal flex flex-wrap items-end justify-between gap-lg border-b border-[var(--border-default)] pb-lg">
|
||||
<SectionHeader
|
||||
id="home-work-heading"
|
||||
title={copy.workHeading}
|
||||
size="display"
|
||||
/>
|
||||
|
||||
{partners.length > 0 ? (
|
||||
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3 lg:gap-x-4xl">
|
||||
{partners.map((partner) => (
|
||||
/* The tile pads its inline start only, as the frame draws it,
|
||||
and sits on the sunken surface - darker than the page, which
|
||||
is the way round the design has it. */
|
||||
<li
|
||||
key={partner.id}
|
||||
className="flex min-h-[7.125rem] items-center gap-md rounded-control bg-[var(--surface-sunken)] ps-md py-sm"
|
||||
>
|
||||
{partner.logo ? (
|
||||
<img
|
||||
src={partner.logo.src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="size-[4.375rem] shrink-0 object-contain"
|
||||
/>
|
||||
) : null}
|
||||
<BandLink to={sectionPath("whatWeDo", locale)} label={copy.workLinkLabel} />
|
||||
</div>
|
||||
|
||||
{/* An editor-supplied URL: `safeHref` refuses anything
|
||||
that is not http/https/mailto/tel, and the plain-text
|
||||
branch below already renders the partner's name. */}
|
||||
{safeHref(partner.website) ? (
|
||||
<a
|
||||
href={safeHref(partner.website)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="body-lg-bold text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
>
|
||||
{partner.name}
|
||||
</a>
|
||||
) : (
|
||||
<span className="body-lg-bold text-[var(--text-primary)]">
|
||||
{partner.name}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
|
||||
{pillars.map((pillar) => (
|
||||
<li
|
||||
key={pillar.title}
|
||||
className="sh-lift sh-reveal sh-reveal--in 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)]"
|
||||
>
|
||||
<h3 className="heading-h3 text-[var(--text-primary)]">{pillar.title}</h3>
|
||||
<p className="body-md text-[var(--text-secondary)]">
|
||||
{pillar.description}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* NEWSROOM */}
|
||||
<PageSection
|
||||
aria-labelledby="home-news-heading"
|
||||
band
|
||||
>
|
||||
<div className="flex flex-col gap-2xl">
|
||||
<div className="sh-reveal flex flex-wrap items-end justify-between gap-lg border-b border-[var(--border-default)] pb-lg">
|
||||
<SectionHeader
|
||||
id="home-news-heading"
|
||||
eyebrow={copy.newsEyebrow}
|
||||
title={copy.newsHeading}
|
||||
size="display"
|
||||
/>
|
||||
|
||||
<BandLink to={sectionPath("news", locale)} label={t("actions.viewAll")} />
|
||||
</div>
|
||||
|
||||
{news.length > 0 ? (
|
||||
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
|
||||
{news.map((item) => (
|
||||
<Card.Content
|
||||
key={item.id}
|
||||
as="li"
|
||||
className="sh-reveal sh-reveal--in"
|
||||
href={item.href}
|
||||
title={item.title}
|
||||
actionLabel={t("actions.readMore")}
|
||||
{...(item.organisation
|
||||
? { eyebrow: t("news.postedBy", { organisation: item.organisation }) }
|
||||
: item.dateLabel
|
||||
? { eyebrow: item.dateLabel }
|
||||
: {})}
|
||||
{...(item.organisation && item.dateLabel
|
||||
? { badge: item.dateLabel }
|
||||
: {})}
|
||||
{...(item.excerpt ? { excerpt: item.excerpt } : {})}
|
||||
{...(item.image ? { image: item.image } : {})}
|
||||
titleAs="h3"
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState message={copy.partnersEmpty} />
|
||||
<EmptyState message={copy.newsEmpty} />
|
||||
)}
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* FAQs - the heading column stays with the reader while the answers
|
||||
scroll beside it, as the draft has it. */}
|
||||
<PageSection
|
||||
aria-labelledby="home-faq-heading"
|
||||
className="bg-[var(--surface-sunken)]"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-2xl lg:grid-cols-2 lg:gap-4xl">
|
||||
<div className="sh-reveal flex flex-col gap-lg lg:sticky lg:top-[var(--layout-sticky-top)] lg:self-start">
|
||||
<SectionHeader
|
||||
id="home-faq-heading"
|
||||
eyebrow={copy.faqEyebrow}
|
||||
title={copy.faqHeading}
|
||||
lead={copy.faqLead}
|
||||
size="display"
|
||||
/>
|
||||
|
||||
<Button
|
||||
as={Link}
|
||||
to={sectionPath("knowledgeHub", locale)}
|
||||
prefetch="intent"
|
||||
size="lg"
|
||||
className="self-start"
|
||||
iconEnd={<ArrowIcon />}
|
||||
>
|
||||
{copy.faqCtaLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{faqs.length > 0 ? (
|
||||
<Accordion items={faqs} className="sh-reveal" />
|
||||
) : (
|
||||
<EmptyState message={copy.faqEmpty} />
|
||||
)}
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* THE CONSORTIUM, then the network it works with */}
|
||||
<PageSection
|
||||
aria-labelledby="home-consortium-heading"
|
||||
band
|
||||
>
|
||||
<div className="flex flex-col gap-2xl">
|
||||
<div className="sh-reveal flex flex-wrap items-end justify-between gap-lg">
|
||||
<SectionHeader
|
||||
id="home-consortium-heading"
|
||||
title={consortiumHeading}
|
||||
size="display"
|
||||
/>
|
||||
|
||||
<p className="label-md uppercase text-[var(--text-tertiary)]">
|
||||
{t("site.euStatement")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
|
||||
{copy.consortium.map((member) => (
|
||||
<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)]"
|
||||
>
|
||||
<span className="rounded-control bg-[var(--surface-accent-subtle)] px-xs py-2xs label-md uppercase text-[var(--text-accent)]">
|
||||
{member.role}
|
||||
</span>
|
||||
<h3 className="heading-h3 text-[var(--text-primary)]">{member.name}</h3>
|
||||
<p className="body-md text-[var(--text-secondary)]">
|
||||
{member.description}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-col gap-md">
|
||||
<h3 className="label-md uppercase text-[var(--text-tertiary)]">
|
||||
{copy.networkLabel}
|
||||
</h3>
|
||||
|
||||
{partners.length > 0 ? (
|
||||
<ul className="grid list-none grid-cols-2 gap-md sm:grid-cols-3 lg:grid-cols-4">
|
||||
{partners.map((partner) => {
|
||||
const href = safeHref(partner.website);
|
||||
|
||||
/* The logo wall is a grid of equal tiles; the name is the
|
||||
tile's text when there is no logo to draw, and always its
|
||||
accessible name. */
|
||||
const inner = (
|
||||
<>
|
||||
{partner.logo ? (
|
||||
<img
|
||||
src={partner.logo.src}
|
||||
alt={partner.name}
|
||||
loading="lazy"
|
||||
className="max-h-[3.5rem] w-auto max-w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="body-sm-bold text-center text-[var(--text-secondary)]">
|
||||
{partner.name}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={partner.id}
|
||||
className={cx(
|
||||
"sh-reveal sh-reveal--in flex h-[5.5rem] items-center justify-center",
|
||||
"rounded-control border border-[var(--border-subtle)] bg-[var(--surface-card)] p-md",
|
||||
"transition-colors duration-[var(--motion-duration-base)]",
|
||||
"hover:border-[var(--border-accent)]",
|
||||
)}
|
||||
>
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
inner
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState message={copy.partnersEmpty} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user