Land the third design drop, seed the CMS, and retire the figures band
Three pieces of one session's work. They are in one commit because home.tsx
and _page-kit/copy.ts carry hunks from all three and splitting them by hand
would have meant committing intermediate states of those two files that were
never built or served.
THE THIRD DESIGN DROP
Only Home.dc.html and SiteFooter.dc.html differ from the second drop; the
other nine page drafts are byte-identical to what already ships.
The home page stops opening on a launch announcement dated to one day in
June. New headline, lead and primary call to action ("See open calls"), and
the launch band becomes "A nationwide project, delivered locally" -
launchEyebrow/Heading/Lead renamed approach*, LaunchShots renamed
GalleryShots, since the band is no longer about an event. Its Arabic reuses
the About page's already-approved wording for the coverage line and the body
heading; the new lead and headline are ar-draft like the rest of the Arabic
home copy and still want client sign-off.
Five motion classes in _page-kit/motion.css, tokens only, each with its RTL
form where the property has no logical one: sh-wipe (the headline uncovered
from the inline start), sh-pan (the hero picture on a view timeline), sh-zoom
(a picture closing in inside its frame), sh-rule-top (an accent rule drawn
across a hovered tile) and sh-marquee (the logo wall). sh-wipe fills
`backwards`, not `both`, so the clip is gone at rest - inset(0) would shave a
descender or an Arabic mark off a tight heading line - and it is removed
outright under prefers-reduced-motion, because a clip does not fade a
headline, it hides it.
The marquee carries each logo twice and slides exactly half the track, so its
spacing is a margin on the tile rather than a gap on the row: with a row gap
the seam lands half a gap short. The second half is aria-hidden with no link,
so the wall is neither read out nor tabbed through twice; it pauses on hover
and focus (WCAG 2.2.2) and unwraps to a plain row under reduced motion. Below
eight partners the wall stays a grid, because a half-track narrower than its
frame drifts a short row across empty space instead of looping.
SEEDING THE CMS
`pnpm seed` writes content through the document service. It opens no port, so
it runs happily beside `pnpm dev:cms`, and every write is matched on a slug
and written over the same document, so a second run changes nothing but the
fields the seed owns.
src/seed/approved.ts is transcribed from docs/ in both languages and is safe
to publish: site settings, the three consortium members, the topic taxonomy,
the five activity streams with their implementing partners, the launch
article in full, and the four pages. What docs/ withholds is not written -
no statistics, no project email, no Facebook URL, no consortium addresses,
all of which the source writes as bracketed placeholders. Two fields are
judgement calls and say so at the line: activityStatus, which the schema
requires and the source never states, and the launch article's year, which is
derived - the source says "Tuesday, June 30" and within December 2025 to
December 2027 that is 2026 and no other year.
src/seed/demo.ts is invented and kept apart three ways: opt-in behind --demo,
refused outright when NODE_ENV=production, and tagged in the data itself with
a demo- slug prefix that shows in every URL and is how --demo --clean finds
them again, with nothing remembered between runs.
Two things the Strapi 5 document service does not do by itself, both of which
leave Arabic rows behind that the next run then collides with on the unique
slug: delete({ documentId }) with no locale removes the DEFAULT locale only,
not the document, and findMany({ locale }) answers for one locale, so a
document whose English half has gone is invisible to a search in English. The
first --clean left six Arabic orphans and failed the next run partway.
deleteMatching now pools documentIds across both locales and deletes with
locale: '*'.
Knex aborts its pending pool acquisitions on teardown, and each abort rejects
a promise nothing awaits, so it arrives as an unhandled rejection rather than
as an error a try/catch would see. scripts/seed.js absorbs exactly that, for
the length of the shutdown only: a seed that worked must not report itself as
a seed that failed.
THE FIGURES BAND, AND THE BAND THAT REPLACED IT
The third draft deletes the figures band, so shared.stat and blocks.stats-grid
are deleted with it, home-page.stats is dropped, and blocks.stats-grid comes
out of all six dynamic zones. On the web side that takes CmsStat, StatsRow,
CountUpValue, StatStrip and Card.Stat - the last two were already dead, but
they existed only to draw figures. No approved document carries a number, so
there is now nowhere for one to be typed in, which is the point.
The "nationwide project, delivered locally" band was half editable and half
not: its pictures came from an image-gallery block down the Blocks dynamic
zone, and its words came from bundled fallback copy with no CMS field at all.
The new page.band-intro component (eyebrow, heading, lead, images) on
home-page.approach puts the whole band in one place in the admin. The page
still falls back to the bundled copy when the fields are empty, and still
reads an image-gallery block after the component's own images, so a gallery
built in Blocks keeps rendering.
VERIFIED
pnpm typecheck and both builds pass. Both rule greps are clean: no raw hex or
tier-1 token in components/ or routes/, no physical-direction utility in
apps/web/app.
Against a seeded CMS on the built server: / redirects 302 with
Vary: Accept-Language, and all ten sections plus the detail routes return 200
in both locales with lang/dir correct in the first bytes. The home page draws
the marquee from twelve partners, the hero pan, the three hovered card rules
and the status pill, and carries no figures band.
The seed is idempotent - two identical --demo runs give identical counts,
balanced across locales - and a clean/re-seed cycle leaves nothing behind.
The approach band was proved editable end to end: heading, eyebrow, lead and
images changed through the CMS all came through on /en while /ar stayed on
its own Arabic, then the seeded copy was restored.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,106 +0,0 @@
|
||||
/* ============================================================================
|
||||
Card.Stat - Figma 25:792 (260 x 166)
|
||||
----------------------------------------------------------------------------
|
||||
The figure card used by the home page stats strip: a large accent number,
|
||||
an optional trend chip, the label it counts, a rule and a caption. Never a
|
||||
link - a number is not a destination.
|
||||
|
||||
USAGE
|
||||
-----
|
||||
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card.Stat
|
||||
as="li"
|
||||
value="2,450"
|
||||
label="Participants reached"
|
||||
caption="Cumulative regional outreach, updated each quarter."
|
||||
trend={{ label: "12%", direction: "up", description: "up 12% on last quarter" }}
|
||||
/>
|
||||
<Card.Stat as="li" value="18" label="Municipalities engaged" />
|
||||
</ul>
|
||||
|
||||
// Arabic - Eastern Arabic numerals belong in the value string itself.
|
||||
<Card.Stat value="٢٬٤٥٠" label="عدد المشاركين" />
|
||||
========================================================================= */
|
||||
|
||||
import { CardIcon, CardShell, cx } from "./CardShell";
|
||||
import type { CardElement } from "./types";
|
||||
|
||||
export interface CardStatTrend {
|
||||
/** Visible text, e.g. "12%". */
|
||||
label: string;
|
||||
/** Up is the accent fill, down the danger fill. Defaults to up. */
|
||||
direction?: "up" | "down";
|
||||
/**
|
||||
* Spoken instead of the bare figure, e.g. "up 12% on last quarter". The
|
||||
* arrow is decorative, so without this a screen reader hears only "12%".
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CardStatProps {
|
||||
/** Pre-formatted for the locale - the card never formats a number itself. */
|
||||
value: string;
|
||||
label: string;
|
||||
caption?: string;
|
||||
trend?: CardStatTrend;
|
||||
as?: CardElement;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CardStat({
|
||||
value,
|
||||
label,
|
||||
caption,
|
||||
trend,
|
||||
as = "article",
|
||||
className,
|
||||
}: CardStatProps) {
|
||||
const isDown = trend?.direction === "down";
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
as={as}
|
||||
className={cx("flex-col items-start gap-md p-lg", className)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-sm">
|
||||
<p className="display-sm tabular-nums text-text-accent">{value}</p>
|
||||
|
||||
{trend ? (
|
||||
/* Figma paints the chip label in text/accent on a background/accent
|
||||
fill - green on green, invisible. Kept as the accent fill with the
|
||||
always-white on-accent label, and the danger fill when the figure
|
||||
is falling. */
|
||||
<span
|
||||
className={cx(
|
||||
"inline-flex shrink-0 items-center gap-2xs rounded-chip px-xs py-2xs",
|
||||
"label-md text-text-on-accent",
|
||||
isDown ? "bg-surface-danger" : "bg-surface-accent",
|
||||
)}
|
||||
>
|
||||
<CardIcon
|
||||
name="arrow-up"
|
||||
size={10}
|
||||
className={isDown ? "rotate-180" : undefined}
|
||||
/>
|
||||
{trend.description ? (
|
||||
<>
|
||||
<span aria-hidden="true">{trend.label}</span>
|
||||
<span className="sr-only">{trend.description}</span>
|
||||
</>
|
||||
) : (
|
||||
trend.label
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="body-md-bold text-text-primary">{label}</p>
|
||||
|
||||
<div aria-hidden="true" className="w-full border-t border-border-subtle" />
|
||||
|
||||
{caption ? (
|
||||
<p className="body-sm text-text-secondary">{caption}</p>
|
||||
) : null}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
/* ============================================================================
|
||||
CARD - Figma frame 25:809
|
||||
----------------------------------------------------------------------------
|
||||
Five card types that share one surface, one link mechanism and one icon
|
||||
Four card types that share one surface, one link mechanism and one icon
|
||||
strategy. Import the namespace for readable markup:
|
||||
|
||||
import { Card } from "~/components/ui/Card";
|
||||
<Card.Content … /> <Card.Team … /> <Card.Horizontal … />
|
||||
<Card.Stat … /> <Card.CTA … />
|
||||
<Card.Content … /> <Card.Team … />
|
||||
<Card.Horizontal … /> <Card.CTA … />
|
||||
|
||||
or the components directly when you want the narrowest import:
|
||||
|
||||
import { CardStat } from "~/components/ui/Card";
|
||||
import { CardTeam } from "~/components/ui/Card";
|
||||
|
||||
The fifth, Card.Stat, is gone along with the rest of the project's figures
|
||||
machinery: no approved document carries a number, so nothing drew it.
|
||||
|
||||
Every card is correct on both surfaces without a prop: colour comes only
|
||||
from tier-2 roles, which [data-surface="dark"] and [data-surface="light"]
|
||||
@@ -20,7 +23,6 @@
|
||||
export { CardContent } from "./CardContent";
|
||||
export { CardTeam } from "./CardTeam";
|
||||
export { CardHorizontal } from "./CardHorizontal";
|
||||
export { CardStat } from "./CardStat";
|
||||
export { CardCTA } from "./CardCTA";
|
||||
|
||||
export {
|
||||
@@ -39,7 +41,6 @@ export type {
|
||||
CardTeamSocialKind,
|
||||
} from "./CardTeam";
|
||||
export type { CardHorizontalProps } from "./CardHorizontal";
|
||||
export type { CardStatProps, CardStatTrend } from "./CardStat";
|
||||
export type { CardCTAProps, CardCTAAction } from "./CardCTA";
|
||||
export type {
|
||||
CardShellProps,
|
||||
@@ -58,14 +59,12 @@ export type {
|
||||
import { CardContent } from "./CardContent";
|
||||
import { CardTeam } from "./CardTeam";
|
||||
import { CardHorizontal } from "./CardHorizontal";
|
||||
import { CardStat } from "./CardStat";
|
||||
import { CardCTA } from "./CardCTA";
|
||||
|
||||
/** The card family, namespaced: Card.Content, Card.Team, Card.Horizontal, Card.Stat, Card.CTA. */
|
||||
/** The card family, namespaced: Card.Content, Card.Team, Card.Horizontal, Card.CTA. */
|
||||
export const Card = {
|
||||
Content: CardContent,
|
||||
Team: CardTeam,
|
||||
Horizontal: CardHorizontal,
|
||||
Stat: CardStat,
|
||||
CTA: CardCTA,
|
||||
} as const;
|
||||
|
||||
@@ -287,7 +287,9 @@ function SocialButton({ link }: { link: FooterSocialLink }) {
|
||||
)}
|
||||
>
|
||||
<FooterSocialIcon platform={link.platform} className="h-[17px] w-[17px]" />
|
||||
<span className="body-sm-bold">{link.label}</span>
|
||||
{/* The label never wraps under its own icon: "Follow us on Facebook" is
|
||||
one line at every width, as the draft has it. */}
|
||||
<span className="body-sm-bold whitespace-nowrap">{link.label}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,11 @@ export interface AccordionProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The +/- mark. The upright bar collapses as the question opens. */
|
||||
/**
|
||||
* The +/- mark. The upright bar collapses as the question opens, and the whole
|
||||
* mark turns half a revolution with it - the design draft's own gesture, which
|
||||
* carries the change of state even where the thin bar is hard to follow.
|
||||
*/
|
||||
function ToggleMark({ open }: { open: boolean }) {
|
||||
return (
|
||||
<span
|
||||
@@ -50,8 +54,8 @@ function ToggleMark({ open }: { open: boolean }) {
|
||||
className={cx(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-pill",
|
||||
"border border-[var(--border-default)] text-[var(--text-accent)]",
|
||||
"transition-colors duration-[var(--motion-duration-base)]",
|
||||
open && "border-[var(--border-accent)] bg-[var(--surface-accent-subtle)]",
|
||||
"transition duration-[var(--motion-duration-base)]",
|
||||
open && "rotate-180 border-[var(--border-accent)] bg-[var(--surface-accent-subtle)]",
|
||||
)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
|
||||
@@ -38,11 +38,19 @@ export interface CmsHero {
|
||||
readonly ctas?: readonly CmsCta[] | null;
|
||||
}
|
||||
|
||||
/** shared.stat - `value` is authored as a string so "48%" and "1,583" survive. */
|
||||
export interface CmsStat {
|
||||
/**
|
||||
* page.band-intro - a page band's own heading, lead and pictures.
|
||||
*
|
||||
* It exists so a band is edited in one place. The alternative - a headline in
|
||||
* one field and its pictures somewhere down the Blocks zone - is how an editor
|
||||
* ends up unable to find half of a section.
|
||||
*/
|
||||
export interface CmsBandIntro {
|
||||
readonly id?: number;
|
||||
readonly value: string;
|
||||
readonly label: string;
|
||||
readonly eyebrow?: string | null;
|
||||
readonly heading?: string | null;
|
||||
readonly lead?: string | null;
|
||||
readonly images?: readonly StrapiMedia[] | null;
|
||||
}
|
||||
|
||||
/** shared.titled-text - pillars and principles. */
|
||||
@@ -175,7 +183,8 @@ export interface CmsFaq extends StrapiEntity {
|
||||
/** api::home-page.home-page */
|
||||
export interface CmsHomePage extends StrapiEntity {
|
||||
readonly hero?: CmsHero | null;
|
||||
readonly stats?: readonly CmsStat[] | null;
|
||||
/** The band under the hero - "A nationwide project, delivered locally". */
|
||||
readonly approach?: CmsBandIntro | null;
|
||||
readonly blocks?: readonly CmsBlock[] | null;
|
||||
/** Curated newsroom picks; the page falls back to the latest articles. */
|
||||
readonly featuredNews?: readonly CmsNewsArticle[] | null;
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
PAGE COPY - EN / AR
|
||||
----------------------------------------------------------------------------
|
||||
SOURCE
|
||||
Home page Figma frame 253:4799 "Home-en" is the source of truth.
|
||||
The headline (I253:4800;228:2609), the launch heading
|
||||
(I361:10853;361:11147), the one-paragraph launch lead
|
||||
with its four bold runs (I361:10853;349:8838) and the
|
||||
three captions are transcribed from that frame, NOT
|
||||
from docs/SHARPER Home Page .docx - the design condenses
|
||||
the draft, and the draft itself is headed "Placeholder
|
||||
text for design purposes - not final copy".
|
||||
Home page The Claude Design draft "Home page redesign with news
|
||||
and FAQs" (third drop) is the source of truth. Its
|
||||
headline, lead, primary call to action and the
|
||||
"nationwide project, delivered locally" band are
|
||||
transcribed from that draft, NOT from docs/SHARPER Home
|
||||
Page .docx - the design condenses the draft, and the
|
||||
draft itself is headed "Placeholder text for design
|
||||
purposes - not final copy". Every claim in it is
|
||||
nonetheless in docs/: the consortium, the partner
|
||||
groups it works with, the EU funding and the December
|
||||
2027 end date.
|
||||
Everything else docs/SHARPER_Website_Draft_Section_EN_AR.docx
|
||||
(sections 1, 2 and 9), which carries both columns, so
|
||||
no string below is a machine translation of the other
|
||||
@@ -32,8 +35,9 @@
|
||||
Figures. The draft marks every number "TBD" and says they must not be
|
||||
published until confirmed, so there is no statistic anywhere in this file.
|
||||
Figma hides the home page's figures band outright (frame 253:4835 is
|
||||
hidden="true"), so the band renders only once the CMS `stats` component
|
||||
supplies real numbers, and nothing stands in for it until then.
|
||||
hidden="true") and the third design drop deletes it, so the band is gone
|
||||
from the page and the `stats` component is gone from the CMS. There is
|
||||
nowhere left for a figure to be typed in, which is the point.
|
||||
========================================================================= */
|
||||
|
||||
import type { Locale } from "~/i18n";
|
||||
@@ -69,17 +73,13 @@ export interface HomeCopy {
|
||||
readonly heroCtaPrimary: string;
|
||||
readonly heroCtaSecondary: string;
|
||||
/**
|
||||
* The figures band. Bundled so the band is never empty, and overridden by
|
||||
* `home-page.stats` the moment an editor writes one. Every figure here is
|
||||
* verifiable: three consortium members and the Dec 2025 - Dec 2027 run come
|
||||
* from the project brief, the four areas of work are the What We Do
|
||||
* workstreams, and the sixteen human security sub-projects are the approved
|
||||
* EN/AR draft's own wording.
|
||||
* The band under the hero picture: what the project is and who delivers it.
|
||||
* It replaces the launch-event band of the previous drop, which had dated
|
||||
* the whole home page to a single day in June.
|
||||
*/
|
||||
readonly stats: readonly { readonly value: string; readonly label: string }[];
|
||||
readonly launchEyebrow: string;
|
||||
readonly launchHeading: string;
|
||||
readonly launchLead: string;
|
||||
readonly approachEyebrow: string;
|
||||
readonly approachHeading: string;
|
||||
readonly approachLead: string;
|
||||
readonly workHeading: string;
|
||||
readonly workLinkLabel: string;
|
||||
readonly newsEyebrow: string;
|
||||
@@ -194,21 +194,17 @@ export interface PageCopy {
|
||||
|
||||
const EN: PageCopy = {
|
||||
home: {
|
||||
heroHeadline: "SHARP-ER Project Officially Launches in Libya",
|
||||
heroHeadline: "Advancing Justice and Accountability, Led by Libyan Communities",
|
||||
heroLead:
|
||||
"Shared Action on Rule of Law Progress — Exit Route: a nationwide initiative supporting Libya’s civil society, local institutions, and communities in advancing justice, accountability, and good governance.",
|
||||
heroCtaPrimary: "Expression of Interest",
|
||||
"SHARP-ER — Shared Action on Rule of Law Progress, Exit Route — works with civil society organisations, municipalities, and community leaders across Libya to strengthen access to justice, accountability, and local governance. Funded by the European Union, running to December 2027.",
|
||||
heroCtaPrimary: "See open calls",
|
||||
heroCtaSecondary: "About the project",
|
||||
stats: [
|
||||
{ value: "3", label: "Consortium partners" },
|
||||
{ value: "4", label: "Areas of work" },
|
||||
{ value: "16", label: "Human security sub-projects" },
|
||||
{ value: "25 mo", label: "Dec 2025 – Dec 2027" },
|
||||
],
|
||||
launchEyebrow: "30 June · Launch event",
|
||||
launchHeading: "An important start for the project",
|
||||
launchLead:
|
||||
"The launch brought together representatives from the European Union Delegation, the Ministry of Youth, the Ministry of Local Governance, the Ministry of Women’s Affairs, civil society organisations, partners, and stakeholders from across Libya. The event marked the opening of the Expression of Interest and the launch of this website.",
|
||||
approachEyebrow: "Nationwide, Libya",
|
||||
/* The same sentence the About page heads its body with - one project, one
|
||||
description of it. */
|
||||
approachHeading: "A nationwide project, delivered locally",
|
||||
approachLead:
|
||||
"Local organisations lead the design and delivery of the work. SHARP-ER partners with Libyan civil society, municipalities, and community leaders — through funded sub-projects, training, and open dialogue between communities and the institutions that serve them.",
|
||||
workHeading: "Three areas of change",
|
||||
workLinkLabel: "What we do",
|
||||
newsEyebrow: "Newsroom",
|
||||
@@ -383,23 +379,17 @@ const EN: PageCopy = {
|
||||
|
||||
const AR: PageCopy = {
|
||||
home: {
|
||||
heroHeadline: "انطلاق مشروع SHARP-ER رسميًا في ليبيا",
|
||||
heroHeadline: "تعزيز العدالة والمساءلة بقيادة المجتمعات الليبية",
|
||||
heroLead:
|
||||
"العمل المشترك لتعزيز سيادة القانون – مسار الخروج: مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات في ليبيا في تعزيز العدالة والمساءلة والحوكمة الرشيدة.",
|
||||
heroCtaPrimary: "الدعوة للتعبير عن الاهتمام",
|
||||
"يعمل SHARP-ER — العمل المشترك لتعزيز سيادة القانون، مسار الخروج — مع منظمات المجتمع المدني والبلديات وقادة المجتمعات في مختلف أنحاء ليبيا على تعزيز الوصول إلى العدالة والمساءلة والحوكمة المحلية. بتمويل من الاتحاد الأوروبي، ويستمر حتى ديسمبر 2027.",
|
||||
heroCtaPrimary: "اطّلع على الدعوات المفتوحة",
|
||||
heroCtaSecondary: "عن المشروع",
|
||||
/* Western digits, as the rest of the Arabic draft writes its numbers
|
||||
("ديسمبر 2025", "SHARP I and II (2019–2024)"). */
|
||||
stats: [
|
||||
{ value: "3", label: "شركاء الائتلاف" },
|
||||
{ value: "4", label: "مجالات العمل" },
|
||||
{ value: "16", label: "مشروعًا فرعيًا للأمن الإنساني" },
|
||||
{ value: "25 شهرًا", label: "ديسمبر 2025 – ديسمبر 2027" },
|
||||
],
|
||||
launchEyebrow: "30 يونيو · فعالية الإطلاق",
|
||||
launchHeading: "بداية مهمة للمشروع",
|
||||
launchLead:
|
||||
"جمعت فعالية الإطلاق ممثلين عن بعثة الاتحاد الأوروبي، ووزارة الشباب، ووزارة الحكم المحلي، ووزارة شؤون المرأة، ومنظمات المجتمع المدني، والشركاء، وأصحاب المصلحة من مختلف أنحاء ليبيا. وشهد الحدث افتتاح الدعوة للتعبير عن الاهتمام وإطلاق هذا الموقع الإلكتروني.",
|
||||
/* The same two lines the About page uses for its coverage fact and its
|
||||
body heading, so the Arabic reads identically on both pages. */
|
||||
approachEyebrow: "على مستوى ليبيا بأكملها",
|
||||
approachHeading: "مشروع وطني يُنفَّذ محليًا",
|
||||
approachLead:
|
||||
"تتولّى المنظمات المحلية تصميم العمل وتنفيذه. ويعمل SHARP-ER بالشراكة مع المجتمع المدني الليبي والبلديات وقادة المجتمعات، من خلال المشاريع الفرعية المموَّلة، والتدريب، والحوار المفتوح بين المجتمعات والمؤسسات التي تخدمها.",
|
||||
workHeading: "ثلاثة مجالات للتغيير",
|
||||
workLinkLabel: "ماذا نفعل",
|
||||
newsEyebrow: "غرفة الأخبار",
|
||||
|
||||
@@ -23,11 +23,12 @@
|
||||
scroll block is additionally gated on `prefers-reduced-motion: no-preference`
|
||||
so a reduced-motion visitor never gets a timeline-driven opacity ramp at all.
|
||||
|
||||
DIRECTION: `transform-origin` and `linear-gradient()` have no logical
|
||||
keywords, so the progress bar and the brand stripe carry an explicit RTL
|
||||
override. These are the only physical directions in the file, and they exist
|
||||
because the property itself has no logical form - every box edge elsewhere is
|
||||
still ps-/pe-/start-/end-.
|
||||
DIRECTION: `transform-origin`, `linear-gradient()`, `clip-path: inset()` and
|
||||
`translateX()` have no logical keywords, so the progress bar, the brand
|
||||
stripe, the headline wipe, the hover rule and the logo marquee each carry an
|
||||
explicit RTL override. These are the only physical directions in the file,
|
||||
and they exist because the property itself has no logical form - every box
|
||||
edge elsewhere is still ps-/pe-/start-/end-.
|
||||
========================================================================= */
|
||||
|
||||
@layer theme, base, components, utilities;
|
||||
@@ -59,6 +60,33 @@
|
||||
--sh-rise-delay: calc(var(--motion-duration-fast) * 3);
|
||||
}
|
||||
|
||||
/* The home headline. The third design drop stops lifting it and uncovers it
|
||||
instead, from the inline start, so the words are read into place rather
|
||||
than slid in. It reads the SAME `--sh-rise-delay` as the cascade above, so
|
||||
`.sh-rise--3` &c. stagger the wipe alongside everything else. */
|
||||
.sh-wipe {
|
||||
animation: sh-wipe var(--motion-duration-reveal) var(--motion-ease-entrance)
|
||||
var(--sh-rise-delay, 0ms) backwards;
|
||||
}
|
||||
|
||||
[dir="rtl"] .sh-wipe {
|
||||
animation-name: sh-wipe-rtl;
|
||||
}
|
||||
|
||||
/* `backwards`, not `both`: the clip has to be GONE once the wipe is over.
|
||||
`inset(0)` at rest would keep clipping to the border box, which shaves a
|
||||
descender or an Arabic mark off a tight heading line. */
|
||||
|
||||
/* app.css shortens every animation but leaves its delay alone, which for a
|
||||
clip means the headline is not merely faded but absent until the delay has
|
||||
passed. Removing the animation outright puts the words on screen from the
|
||||
first paint, which is what a reduced-motion reader asked for. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sh-wipe {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The brand stripe under the hero picture, drawn on from the inline start. */
|
||||
.sh-bar-grow {
|
||||
transform-origin: left center;
|
||||
@@ -93,6 +121,69 @@
|
||||
infinite;
|
||||
}
|
||||
|
||||
/* The network logo wall, drifting past. The track carries the tiles TWICE,
|
||||
so moving it exactly half its own width lands on an identical frame and
|
||||
the loop never shows a seam; 42.5x the reveal duration is the draft's 34s
|
||||
at the shipped token.
|
||||
|
||||
`width` is set here rather than with a `w-max` utility on purpose: the
|
||||
reduced-motion block below has to be able to take it back, and a utility
|
||||
sits in a later layer than this one.
|
||||
|
||||
It stops under the pointer and whenever anything inside it takes focus, so
|
||||
a reader can hold the movement still to look at - or tab to - a logo
|
||||
(WCAG 2.2.2). Under reduced motion it stops being a track at all: the row
|
||||
simply wraps and the duplicate half is removed, so nothing moves and no
|
||||
logo is shown twice. */
|
||||
.sh-marquee {
|
||||
width: max-content;
|
||||
animation: sh-marquee calc(var(--motion-duration-reveal) * 42.5) linear
|
||||
infinite;
|
||||
}
|
||||
|
||||
/* RTL reads the other way, so the drift does too. */
|
||||
[dir="rtl"] .sh-marquee {
|
||||
animation-name: sh-marquee-rtl;
|
||||
}
|
||||
|
||||
.sh-marquee:hover,
|
||||
.sh-marquee:focus-within {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sh-marquee {
|
||||
width: auto;
|
||||
flex-wrap: wrap;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.sh-marquee__clone {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The wall fades out at both ends instead of being cut off mid-tile. `black`
|
||||
and `transparent` here are alpha, not colour - a mask reads the channel
|
||||
only - which is why this is not a surface token. The ramp is symmetric, so
|
||||
it needs no RTL form. */
|
||||
.sh-marquee-mask {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 8%,
|
||||
black 92%,
|
||||
transparent
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 8%,
|
||||
black 92%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
INTERACTION
|
||||
---------------------------------------------------------------------- */
|
||||
@@ -112,6 +203,56 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* A picture pushing in a little while the frame around it is hovered. The
|
||||
frame clips, so the picture grows into its own box and not over the page.
|
||||
Paired with .sh-lift the tile rises and its picture closes in, which is
|
||||
how the draft hovers the gallery. */
|
||||
.sh-zoom {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sh-zoom img {
|
||||
transition: transform var(--motion-duration-slow)
|
||||
var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.sh-zoom:hover img {
|
||||
transform: scale(1.09);
|
||||
}
|
||||
}
|
||||
|
||||
/* An accent rule drawn across the top of a tile under the pointer, growing
|
||||
from the inline start. It is a pseudo-element so the tile's own markup
|
||||
stays a heading and a paragraph. */
|
||||
.sh-rule-top {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sh-rule-top::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline: 0;
|
||||
block-size: 3px;
|
||||
background: var(--surface-accent);
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition: transform var(--motion-duration-slow)
|
||||
var(--motion-ease-entrance);
|
||||
}
|
||||
|
||||
[dir="rtl"] .sh-rule-top::before {
|
||||
transform-origin: right center;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.sh-rule-top:hover::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* An FAQ answer opening under its question. */
|
||||
.sh-answer {
|
||||
animation: sh-answer var(--motion-duration-base) var(--motion-ease-entrance)
|
||||
@@ -134,6 +275,16 @@
|
||||
animation-name: sh-reveal-in;
|
||||
animation-range: entry 0% entry 80%;
|
||||
}
|
||||
|
||||
/* 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
|
||||
simply sits still, which is the state the design falls back to. */
|
||||
.sh-pan {
|
||||
animation: sh-pan linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 100% exit 0%;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (animation-timeline: scroll()) {
|
||||
@@ -236,6 +387,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sh-wipe {
|
||||
from {
|
||||
opacity: 0.4;
|
||||
clip-path: inset(0 100% 0 0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sh-wipe-rtl {
|
||||
from {
|
||||
opacity: 0.4;
|
||||
clip-path: inset(0 0 0 100%);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sh-marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sh-marquee-rtl {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sh-pan {
|
||||
from {
|
||||
transform: translateY(-5%) scale(1.14);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(5%) scale(1.14);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sh-answer {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -393,6 +393,12 @@ export function PageHero({
|
||||
const rise = (step?: 2 | 3 | 4 | 5) =>
|
||||
reveal ? cx("sh-rise", step && `sh-rise--${step}`) : undefined;
|
||||
|
||||
/* The same cascade, uncovering the words instead of lifting them - the
|
||||
headline treatment of the home draft. `.sh-wipe` reads the very step
|
||||
classes `rise` sets, so both share one stagger. */
|
||||
const wipe = (step?: 2 | 3 | 4 | 5) =>
|
||||
reveal ? cx("sh-wipe", step && `sh-rise--${step}`) : undefined;
|
||||
|
||||
const washes = ambient ?? [];
|
||||
|
||||
return (
|
||||
@@ -452,12 +458,27 @@ export function PageHero({
|
||||
{leadWithActions ? (
|
||||
<>
|
||||
{/* Headline across the top ... */}
|
||||
<div className={cx("flex max-w-[43.75rem] flex-col gap-sm", rise(3))}>
|
||||
<div className="flex max-w-[43.75rem] flex-col gap-sm">
|
||||
{eyebrow ? (
|
||||
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
|
||||
<p
|
||||
className={cx(
|
||||
"label-md uppercase text-[var(--text-accent)]",
|
||||
rise(3),
|
||||
)}
|
||||
>
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<h1 className="heading-h1 text-[var(--text-primary)] md:display-sm">
|
||||
{/* The wipe is on the heading itself, not on the column, so the
|
||||
eyebrow above it still arrives with the rest of the cascade
|
||||
rather than being uncovered along with the words. */}
|
||||
<h1
|
||||
className={cx(
|
||||
"heading-h1 text-[var(--text-primary)] md:display-sm",
|
||||
wipe(3),
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -539,13 +560,18 @@ export function PageHero({
|
||||
mint tint over a shade, both painted on the photograph. */
|
||||
<div className={cx("w-full", rise(5))}>
|
||||
<div className="relative w-full overflow-hidden">
|
||||
{/* `sh-pan` drifts the picture inside the band as the page
|
||||
scrolls past it. It is scaled up inside the frame first,
|
||||
so the drift never uncovers an edge; the washes above are
|
||||
positioned against the frame, not the picture, so they do
|
||||
not travel with it. */}
|
||||
<img
|
||||
src={media.src}
|
||||
alt={media.alt}
|
||||
{...(media.width ? { width: media.width } : {})}
|
||||
{...(media.height ? { height: media.height } : {})}
|
||||
fetchPriority="high"
|
||||
className="aspect-[16/9] w-full object-cover md:aspect-[1244/415]"
|
||||
className="sh-pan aspect-[16/9] w-full object-cover md:aspect-[1244/415]"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -747,39 +773,3 @@ export function ValueChips({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface StatStripProps {
|
||||
stats: readonly { value: string; label: string }[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The figures band. `value` is authored in the CMS as a string, already
|
||||
* formatted for its locale, because the site must never invent or re-derive a
|
||||
* project figure - see the note the pages render when this list is empty.
|
||||
*/
|
||||
export function StatStrip({ stats, className }: StatStripProps) {
|
||||
if (stats.length === 0) return null;
|
||||
|
||||
return (
|
||||
<dl
|
||||
className={cx(
|
||||
"grid grid-cols-2 gap-lg border-y border-[var(--border-subtle)] py-xl md:grid-cols-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{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 text-start"
|
||||
>
|
||||
<dt className="body-sm text-[var(--text-secondary)]">{stat.label}</dt>
|
||||
<dd className="heading-h1 text-[var(--text-accent)]">{stat.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
+171
-179
@@ -1,15 +1,18 @@
|
||||
/* ============================================================================
|
||||
HOME - /en and /ar
|
||||
----------------------------------------------------------------------------
|
||||
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:
|
||||
The redesigned home page (Claude Design "Home page redesign with news and
|
||||
FAQs", third 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 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.
|
||||
the CTA pair, the wide picture and the brand stripe. Two
|
||||
slow washes drift behind it.
|
||||
Approach "A nationwide project, delivered locally": what the project
|
||||
does, and up to three pictures. Heading, lead and pictures
|
||||
are all `home-page.approach`, so the band is edited in one
|
||||
place rather than half in a field and half in Blocks.
|
||||
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.
|
||||
@@ -20,25 +23,26 @@
|
||||
--------------------------------------------
|
||||
* 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
|
||||
(CLAUDE.md rule 5). Everything else in the draft is verifiable against
|
||||
docs/ or the bundled draft copy.
|
||||
* The figures band the second drop carried is gone, as it is from the third
|
||||
draft, and so is the `home-page.stats` component behind it: no approved
|
||||
document carries a number, so there was never anything to put in it.
|
||||
* Every list - news, questions, partner logos, band 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.
|
||||
|
||||
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.
|
||||
_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.
|
||||
|
||||
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";
|
||||
@@ -74,6 +78,7 @@ import {
|
||||
toImage,
|
||||
toPartner,
|
||||
type ResolvedImage,
|
||||
type ResolvedPartner,
|
||||
} from "./_page-kit/cms.server";
|
||||
import { pageCopy } from "./_page-kit/copy";
|
||||
import { routeLocale } from "./_page-kit/locale";
|
||||
@@ -90,12 +95,21 @@ import "./_page-kit/motion.css";
|
||||
|
||||
/** The network logo wall - a wide but finite grid. */
|
||||
const PARTNER_PAGE_SIZE = 36;
|
||||
/**
|
||||
* Below this many partners the wall stays a grid.
|
||||
*
|
||||
* The marquee loops by carrying the tiles twice and sliding exactly half the
|
||||
* track, which only reads as a loop while the half is wider than the frame it
|
||||
* slides through. Eight 178px tiles clear a desktop container; fewer would
|
||||
* drift a short row across an empty band, so the grid keeps them still.
|
||||
*/
|
||||
const PARTNER_MARQUEE_MIN = 8;
|
||||
/** 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;
|
||||
/** The draft's approach band shows three pictures. */
|
||||
const GALLERY_SHOTS = 3;
|
||||
|
||||
/** A newsroom card, already localised and safe to serialise to the client. */
|
||||
interface NewsCard {
|
||||
@@ -131,7 +145,7 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
locale,
|
||||
populate: {
|
||||
hero: { populate: { media: true, ctas: true } },
|
||||
stats: true,
|
||||
approach: { populate: { images: 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 } },
|
||||
@@ -174,9 +188,15 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
pagination: { pageSize: FAQ_PAGE_SIZE },
|
||||
});
|
||||
|
||||
const gallery = list(page?.blocks)
|
||||
.filter(isImageGalleryBlock)
|
||||
.flatMap((block) => list(block.images))
|
||||
/* The band's own pictures come first. An `image-gallery` block is still
|
||||
read after them, so a gallery an editor built in the Blocks zone before
|
||||
the band had a media field of its own keeps rendering. */
|
||||
const gallery = [
|
||||
...list(page?.approach?.images),
|
||||
...list(page?.blocks)
|
||||
.filter(isImageGalleryBlock)
|
||||
.flatMap((block) => list(block.images)),
|
||||
]
|
||||
.map((image) => toImage(image, "medium"))
|
||||
.filter((image): image is ResolvedImage => image !== undefined);
|
||||
|
||||
@@ -193,12 +213,12 @@ 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.slice(0, LAUNCH_SHOTS),
|
||||
approach: {
|
||||
eyebrow: page?.approach?.eyebrow ?? null,
|
||||
heading: page?.approach?.heading ?? null,
|
||||
lead: page?.approach?.lead ?? null,
|
||||
},
|
||||
gallery: gallery.slice(0, GALLERY_SHOTS),
|
||||
partnersHeadline: page?.partnersHeadline ?? null,
|
||||
partners: partners.map(toPartner),
|
||||
news: news.slice(0, NEWS_PAGE_SIZE).map((article) => toNewsCard(article, locale)),
|
||||
@@ -291,93 +311,17 @@ function StatusPill({ label }: { label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* `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 (
|
||||
<>
|
||||
{shown}
|
||||
{suffix}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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
|
||||
* The approach 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.
|
||||
*
|
||||
* The frame and the picture hover apart, as the third draft has them: the
|
||||
* frame lifts and takes the accent hairline (`sh-lift`) while the picture
|
||||
* closes in inside it (`sh-zoom`, which also does the clipping).
|
||||
*/
|
||||
function LaunchShots({ images }: { images: readonly ResolvedImage[] }) {
|
||||
function GalleryShots({ images }: { images: readonly ResolvedImage[] }) {
|
||||
if (images.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -385,12 +329,14 @@ function LaunchShots({ images }: { images: readonly ResolvedImage[] }) {
|
||||
{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)]"
|
||||
/>
|
||||
<div className="sh-lift sh-zoom rounded-control border border-[var(--border-subtle)] hover:border-[var(--border-accent)]">
|
||||
<img
|
||||
src={image.src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-[15rem] w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{image.alt ? (
|
||||
<figcaption className="body-sm-bold text-[var(--text-secondary)]">
|
||||
{image.alt}
|
||||
@@ -403,6 +349,74 @@ function LaunchShots({ images }: { images: readonly ResolvedImage[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One tile on the civil society logo wall.
|
||||
*
|
||||
* `decorative` renders the second half of the marquee track: the same tile,
|
||||
* hidden from assistive technology, with no link and no alt text, so the loop
|
||||
* repeats visually without repeating the wall for a screen reader or adding a
|
||||
* second tab stop per partner.
|
||||
*/
|
||||
function PartnerTile({
|
||||
partner,
|
||||
fixedWidth = false,
|
||||
decorative = false,
|
||||
}: {
|
||||
partner: ResolvedPartner;
|
||||
fixedWidth?: boolean;
|
||||
decorative?: boolean;
|
||||
}) {
|
||||
const href = decorative ? undefined : safeHref(partner.website);
|
||||
|
||||
/* The logo wall is a wall 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={decorative ? "" : 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
|
||||
{...(decorative ? { "aria-hidden": true } : {})}
|
||||
className={cx(
|
||||
"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)]",
|
||||
/* A still grid reveals tile by tile; a travelling track must not, or
|
||||
each tile would ramp its own opacity while it moves.
|
||||
|
||||
The track's spacing is a margin on the tile rather than a `gap` on
|
||||
the row: the loop slides exactly half the track, so each half has to
|
||||
carry its own trailing space or the seam lands half a gap short. */
|
||||
fixedWidth ? "me-md w-[11.125rem] shrink-0" : "sh-reveal sh-reveal--in",
|
||||
decorative && "sh-marquee__clone",
|
||||
)}
|
||||
>
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
inner
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** The "see the whole section" link the draft puts opposite a band heading. */
|
||||
function BandLink({ to, label }: { to: string; label: string }) {
|
||||
return (
|
||||
@@ -417,7 +431,7 @@ function BandLink({ to, label }: { to: string; label: string }) {
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
const { locale, hero, stats, gallery, partners, news, faqs } = loaderData;
|
||||
const { locale, hero, approach, 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
|
||||
@@ -426,7 +440,6 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
const headline = hero.headline ?? copy.heroHeadline;
|
||||
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
|
||||
@@ -462,7 +475,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
/>
|
||||
|
||||
<PageHero
|
||||
className="bg-[var(--surface-sunken)] [--layout-hero-top:clamp(2.5rem,6vw,4.5rem)] [--layout-hero-bottom:var(--spacing-2xl)]"
|
||||
className="bg-[var(--surface-sunken)] [--layout-hero-top:clamp(2.5rem,6vw,4.5rem)] [--layout-hero-bottom:var(--spacing-md)]"
|
||||
title={headline}
|
||||
lead={lead}
|
||||
leadWithActions
|
||||
@@ -500,28 +513,29 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
actions={heroCtas.map((cta) => (
|
||||
<HeroCta key={`${cta.href}-${cta.label}`} {...cta} />
|
||||
))}
|
||||
below={<StatsRow stats={figures} />}
|
||||
/>
|
||||
|
||||
{/* THE LAUNCH */}
|
||||
{/* WHAT THE PROJECT IS, AND WHO DELIVERS IT */}
|
||||
<PageSection
|
||||
aria-labelledby="home-launch-heading"
|
||||
aria-labelledby="home-approach-heading"
|
||||
band
|
||||
>
|
||||
<div className="flex flex-col gap-3xl">
|
||||
<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}
|
||||
id="home-approach-heading"
|
||||
eyebrow={approach.eyebrow ?? copy.approachEyebrow}
|
||||
title={approach.heading ?? copy.approachHeading}
|
||||
size="display"
|
||||
align="center"
|
||||
/>
|
||||
|
||||
<p className="body-lg text-[var(--text-secondary)]">{copy.launchLead}</p>
|
||||
<p className="body-lg text-[var(--text-secondary)]">
|
||||
{approach.lead ?? copy.approachLead}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LaunchShots images={gallery} />
|
||||
<GalleryShots images={gallery} />
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
@@ -545,7 +559,9 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
{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)]"
|
||||
/* `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)]"
|
||||
>
|
||||
<h3 className="heading-h3 text-[var(--text-primary)]">{pillar.title}</h3>
|
||||
<p className="body-md text-[var(--text-secondary)]">
|
||||
@@ -680,59 +696,35 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
{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>
|
||||
) : (
|
||||
{partners.length === 0 ? (
|
||||
<EmptyState message={copy.partnersEmpty} />
|
||||
) : partners.length >= PARTNER_MARQUEE_MIN ? (
|
||||
/* The wall drifts, as the third draft draws it. The track holds
|
||||
the tiles twice so the loop is seamless; the mask fades it out
|
||||
at both ends instead of cutting a tile in half. Both halves
|
||||
come from one list, so the wall can never show a partner the
|
||||
CMS does not hold. */
|
||||
<div className="sh-marquee-mask relative overflow-hidden">
|
||||
<ul className="sh-marquee flex list-none">
|
||||
{partners.map((partner) => (
|
||||
<PartnerTile key={partner.id} partner={partner} fixedWidth />
|
||||
))}
|
||||
{partners.map((partner) => (
|
||||
<PartnerTile
|
||||
key={`repeat-${partner.id}`}
|
||||
partner={partner}
|
||||
fixedWidth
|
||||
decorative
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid list-none grid-cols-2 gap-md sm:grid-cols-3 lg:grid-cols-4">
|
||||
{partners.map((partner) => (
|
||||
<PartnerTile key={partner.id} partner={partner} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user