/* ============================================================================ PAGE KIT - section primitives ---------------------------------------------------------------------------- The handful of layout pieces every page module repeats: the centred content column, a vertical rhythm for sections, a section header, the page hero and the empty state that renders while the CMS has no entry yet. RULES THIS FILE FOLLOWS ----------------------- * tier-2 semantic tokens only - no hex, no tier-1 primitive, no raw type size (the type role utilities carry family/size/leading/tracking/weight) * logical properties only (ps-/pe-/ms-/me-/start-/end-, text-start) so the Arabic pages mirror without a single `rtl:` override * no component here duplicates Button / Input / Card - those are imported from ~/components/ui by the pages themselves ========================================================================= */ import type { ReactNode } from "react"; import "./hero.css"; import "./motion.css"; export function cx(...parts: Array): string { return parts.filter(Boolean).join(" "); } /* --------------------------------------------------------------------------- LAYOUT ------------------------------------------------------------------------ */ export interface ContainerProps { children: ReactNode; className?: string; } /** The centred content column every section shares. */ export function Container({ children, className }: ContainerProps) { return (
{children}
); } export interface PageSectionProps { children: ReactNode; id?: string; /** Flip this section (and only this section) to the other surface. */ surface?: "dark" | "light"; className?: string; /** * Paint this section as one of the drafts' lighter #1D1D1B bands. Cards * inside it then draw themselves a step darker, as the drafts do. */ band?: boolean; /** Content is placed directly in the section, without the centred column. */ bleed?: boolean; "aria-labelledby"?: string; /** For a band whose heading is not on the page - a figures row, a logo wall. */ "aria-label"?: string; } /** * One band of the page. `surface` re-points the whole tier-2 palette for the * subtree, so a light band needs no colour classes of its own. */ export function PageSection({ children, id, surface, band = false, className, bleed = false, ...rest }: PageSectionProps) { return (
{bleed ? children : {children}}
); } /** A readable measure for long-form prose. */ export function Prose({ children, className }: ContainerProps) { return (
{children}
); } /* --------------------------------------------------------------------------- HEADINGS ------------------------------------------------------------------------ */ export interface SectionHeaderProps { title: string; id?: string; eyebrow?: string; lead?: string; titleAs?: "h1" | "h2" | "h3"; /** * Type role of the title. "h2" is the Heading/H2 role every inner page uses; * "display" is Display/Small - the home page draws its band headings at the * same 44/52 as the hero (figma 361:11147, 393:14570) while keeping them *

in the outline, which `titleAs` alone cannot express. */ size?: "h2" | "display"; /** Figma centres the home page's section headers; inner pages read start. */ align?: "start" | "center"; className?: string; } export function SectionHeader({ title, id, eyebrow, lead, titleAs = "h2", size = "h2", align = "start", className, }: SectionHeaderProps) { const Title = titleAs; const centred = align === "center"; const display = size === "display" || titleAs === "h1"; return (
{eyebrow ? (

{eyebrow}

) : null} {title} {lead ? (

{lead}

) : null}
); } /* --------------------------------------------------------------------------- HERO ------------------------------------------------------------------------ */ export interface PageHeroWordmark { src: string; /** * Empty when the headline already names the project - the lockup is then * decorative and a screen reader would otherwise read the name twice. */ alt: string; /** Intrinsic pixels of the asset, for the aspect ratio the browser reserves. */ width: number; height: number; /** Drawn width in CSS pixels. Figma 253:4800 draws the hero lockup at 406. */ drawnWidth: number; /** * A second mark drawn flush after this one at the same scale, faded in once * the page has rendered, sliding out from behind it - the home hero's * "-ER". Always decorative. */ suffix?: PageHeroWordmarkSuffix; } export interface PageHeroWordmarkSuffix { src: string; /** Intrinsic pixels of the asset, for the aspect ratio the browser reserves. */ width: number; height: number; /** Drawn width in CSS pixels when the lockup is at full size. */ drawnWidth: number; } /** * One soft radial wash drifting behind a hero. Every draft positions its own * differently - which edge it hangs from, how far in, how big and how slowly * it drifts - so the geometry travels with the page rather than being baked * into the hero. * * `offset` is measured from the INLINE start or end, so the wash mirrors in * Arabic along with the rest of the page. */ export interface HeroWash { /** Offset from the hero's top edge in px; negative pulls it above. */ top: number; /** Which inline edge the wash hangs from. */ from: "start" | "end"; /** How far in from that edge - the draft's own value, e.g. "8%" or "-160px". */ offset: string; width: number; height: number; /** Which brand wash. The drafts use green everywhere and red once, on home. */ tone?: "accent" | "danger"; /** Seconds one drift cycle takes; the drafts vary this from 18s to 24s. */ durationSeconds?: number; /** Drift the other way round, as the home hero's red wash does. */ reverse?: boolean; } export interface PageHeroMedia { src: string; alt: string; width?: number; height?: number; } /** * How the picture is drawn. * "rounded" - a 16:9 card with the surface radius. The default, and what the * inner pages have always rendered. * "banner" - the home treatment (figma 253:4800): square corners, a wide * band, and the two flat washes the frame paints over the photo. */ export type PageHeroMediaTreatment = "rounded" | "banner"; export interface PageHeroProps { title: string; eyebrow?: string; lead?: string; /** * Brand lockup drawn above the headline. Only the home hero carries one * (figma 253:4800, a 406x86 wordmark 48px above the headline row). */ wordmark?: PageHeroWordmark; /** * Funding attribution, drawn 24px directly above the wordmark (figma * 478:15951). Only the home hero carries one; every other page relies on the * footer for EU visibility. */ funderBadge?: ReactNode; /** * A short status line drawn as a pill above the headline - the redesign's * "Now live" chip. Only rendered when the caller passes one, so it can never * announce a state the CMS has not authored. */ statusPill?: ReactNode; /** Buttons, rendered inline-end of the headline on wide viewports. */ actions?: ReactNode; /** * Put the lead on its own row, opposite the buttons, instead of under the * headline. The home draft lays the hero out this way - headline across the * top, then the lead and the CTA pair sharing a row, baseline aligned. Every * interior hero stacks eyebrow / headline / lead in one column. */ leadWithActions?: boolean; /** * The washes drifting behind this hero, in the page's own geometry. * Decorative and opt-in: omit it and the hero is flat. */ ambient?: readonly HeroWash[]; /** * Tone of the lead. The drafts set it quiet almost everywhere, but What We * Do prints it in the primary tone, a step up from the body colour. */ leadTone?: "secondary" | "primary"; /** * Stagger the hero's own content in on first paint. Opt-in for the same * reason: only the home hero is designed as an arrival moment. */ reveal?: boolean; /** Wide picture below the headline row, as in Figma 253:4800. */ media?: PageHeroMedia; /** * Content closing the hero band, under the picture and its brand stripe - * the redesign puts the figures row here so it sits on the hero's own * surface rather than opening a band of its own. */ below?: ReactNode; mediaTreatment?: PageHeroMediaTreatment; className?: string; } /** * The lockup above the hero headline. Its size is a graphic dimension, not a * spacing value - set the way Brand.tsx and the footer size their lockups, with * the intrinsic box on each so the browser reserves the right height. * * With a `suffix`, the two marks share one row. The row is pinned to * `dir="ltr"` because a Latin wordmark never mirrors (its position on the page * still follows the page's direction), and each mark takes the share of the * row its drawn width gives it, so the pair shrinks together on a narrow screen * and stays at one scale at every width. */ function HeroWordmark({ wordmark }: { wordmark: PageHeroWordmark }) { const { suffix } = wordmark; if (!suffix) { return ( {wordmark.alt} ); } return (
{wordmark.alt} {/* The clipping box starts at the P's right edge, so the mark inside it can slide out from behind the P (hero.css). */}
); } /** * The page hero - Figma "Hero header" (253:4800): headline on the inline-start * side, the call-to-action group on the inline-end side, and a full-width * picture underneath. The picture is only rendered when the CMS supplies one; * an empty media box would otherwise ship as a grey rectangle. */ export function PageHero({ title, eyebrow, lead, wordmark, funderBadge, statusPill, actions, leadWithActions = false, ambient, leadTone = "secondary", reveal = false, media, mediaTreatment = "rounded", below, className, }: PageHeroProps) { /** Only paid for when the caller asks for the arrival treatment. */ const rise = (step?: 2 | 3 | 4 | 5) => reveal ? cx("sh-rise", step && `sh-rise--${step}`) : undefined; const washes = ambient ?? []; return (
0 && "relative isolate overflow-hidden", className, )} > {washes.length > 0 ? ( /* Flat washes of the brand colours, drifting. Purely decorative: behind the content, never catching the pointer, and hidden from assistive technology. The geometry is the page's own; only the drift itself is shared (motion.css). */ ) : null} {funderBadge || wordmark ? ( /* Figma groups the two (478:16183): the badge, then 24px, then the lockup. The group as a whole keeps the 48px the Container's gap already provides above the headline row. */
{funderBadge} {wordmark ? : null}
) : null} {statusPill ?
{statusPill}
: null} {leadWithActions ? ( <> {/* Headline across the top ... */}
{eyebrow ? (

{eyebrow}

) : null}

{title}

{/* ... then the lead and the buttons sharing a row, sitting on the same baseline, as the home draft draws them. */} {lead || actions ? (
{lead ? (

{lead}

) : null} {actions ? (
{actions}
) : null}
) : null} ) : (
{eyebrow ? (

{eyebrow}

) : null}

{title}

{lead ? (

{lead}

) : null}
{actions ? (
{actions}
) : null}
)} {media ? ( mediaTreatment === "banner" ? ( /* Figma draws a 1244x700 picture inside an 800px frame that clips it, so what the page actually shows is a wide band. The band is kept at 16:9 on a phone, where a 2.9:1 strip would be a sliver. The two washes are flat fills in the frame, not gradients: a mint tint over a shade, both painted on the photograph. */
{media.alt}
{/* The colour band (478:16360): three blocks flush under the picture, 6px tall. Figma lays out 390 / 495 / 363 inside a 1244-wide clipping frame, so the last block loses 4px and the split that is actually drawn is 390 : 495 : 359 - carried here as flex-grow ratios so it holds at every width instead of only at 1244. The three fills are `background/accent`, `background/inverse` and `background/danger`, which is why the middle block reads white on the dark hero and near-black on a light surface. Purely decorative, so it is hidden from assistive technology; as an ordinary flex row it mirrors in Arabic along with the rest of the page. */}
) : ( {media.alt} ) ) : null} {below}
); } /* --------------------------------------------------------------------------- PANELS ------------------------------------------------------------------------ */ export interface InfoPanelProps { title: string; children?: ReactNode; /** Small line above the title - a number, a partner name, a role. */ eyebrow?: string; /** * How the eyebrow is drawn. "chip" is the redesign's filled role tag on the * subtle accent surface; "plain" is the bare accent label inner pages use. */ eyebrowVariant?: "plain" | "chip"; titleAs?: "h2" | "h3" | "h4"; className?: string; } /** * A titled block of text with no destination - a pillar, a workstream, a * principle. Deliberately NOT a Card: every card in the design system is a * link, and these are not. */ export function InfoPanel({ title, children, eyebrow, eyebrowVariant = "plain", titleAs = "h3", className, }: InfoPanelProps) { const Title = titleAs; return (
{eyebrow ? (

{eyebrow}

) : null} {title} {children ? (
{children}
) : null}
); } export interface EmptyStateProps { /** Localised - never an English default. */ message: string; title?: string; className?: string; children?: ReactNode; } /** * What renders where the CMS has nothing yet. It states plainly that the * content is still to come rather than showing an empty grid, and it is always * passed a localised string by the caller. */ export function EmptyState({ message, title, className, children }: EmptyStateProps) { return (
{title ? (

{title}

) : null}

{message}

{children}
); } /* --------------------------------------------------------------------------- SMALL PIECES ------------------------------------------------------------------------ */ export interface ValueChipsProps { values: readonly string[]; label?: string; /** The redesign centres the row on What We Do; inner pages read start. */ align?: "start" | "center"; className?: string; } /** The "What guides us" row: Dialogue · Community Leadership · Learning · … */ export function ValueChips({ values, label, align = "start", className, }: ValueChipsProps) { if (values.length === 0) return null; const centred = align === "center"; return (
{/* Accent, like every other eyebrow in the system (SectionHeader). */} {label ? (

{label}

) : null}
    {values.map((value) => (
  • {value}
  • ))}
); } 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 (
{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. */
{stat.label}
{stat.value}
))}
); }