/* ============================================================================ 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"; 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; /** Content is placed directly in the section, without the centred column. */ bleed?: boolean; "aria-labelledby"?: 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, 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 419. */ drawnWidth: number; } 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 419x58 wordmark 48px above the headline row). */ wordmark?: PageHeroWordmark; /** Buttons, rendered inline-end of the headline on wide viewports. */ actions?: ReactNode; /** Wide picture below the headline row, as in Figma 253:4800. */ media?: PageHeroMedia; mediaTreatment?: PageHeroMediaTreatment; className?: string; } /** * 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, actions, media, mediaTreatment = "rounded", className, }: PageHeroProps) { return (
{wordmark ? ( /* A graphic dimension, not a spacing value - sized the way Brand.tsx and the footer size their lockups, with the intrinsic box on the element so the browser reserves the right height. */ {wordmark.alt} ) : 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}
) : ( {media.alt} ) ) : null}
); } /* --------------------------------------------------------------------------- PANELS ------------------------------------------------------------------------ */ export interface InfoPanelProps { title: string; children?: ReactNode; /** Small line above the title - a number, a partner name, a role. */ eyebrow?: string; 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, 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; className?: string; } /** The "What guides us" row: Dialogue · Community Leadership · Learning · … */ export function ValueChips({ values, label, className }: ValueChipsProps) { if (values.length === 0) return null; return (
{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}
))}
); }