2026-09-11 02:46:56 +02:00
|
|
|
/* ============================================================================
|
|
|
|
|
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 | false | null | undefined>): 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 (
|
|
|
|
|
<div
|
|
|
|
|
className={cx(
|
|
|
|
|
"mx-auto w-full max-w-[var(--layout-content-max)] px-[var(--layout-gutter)] lg:px-[var(--layout-gutter-lg)]",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<section
|
|
|
|
|
id={id}
|
|
|
|
|
{...(surface ? { "data-surface": surface } : {})}
|
|
|
|
|
className={cx("py-3xl md:py-4xl", className)}
|
|
|
|
|
{...rest}
|
|
|
|
|
>
|
|
|
|
|
{bleed ? children : <Container>{children}</Container>}
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** A readable measure for long-form prose. */
|
|
|
|
|
export function Prose({ children, className }: ContainerProps) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={cx("flex max-w-[var(--layout-prose-max)] flex-col gap-md", className)}>
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
HEADINGS
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
export interface SectionHeaderProps {
|
|
|
|
|
title: string;
|
|
|
|
|
id?: string;
|
|
|
|
|
eyebrow?: string;
|
|
|
|
|
lead?: string;
|
|
|
|
|
titleAs?: "h1" | "h2" | "h3";
|
2026-09-14 12:38:05 +02:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
* <h2> in the outline, which `titleAs` alone cannot express.
|
|
|
|
|
*/
|
|
|
|
|
size?: "h2" | "display";
|
2026-09-11 02:46:56 +02:00
|
|
|
/** 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",
|
2026-09-14 12:38:05 +02:00
|
|
|
size = "h2",
|
2026-09-11 02:46:56 +02:00
|
|
|
align = "start",
|
|
|
|
|
className,
|
|
|
|
|
}: SectionHeaderProps) {
|
|
|
|
|
const Title = titleAs;
|
|
|
|
|
const centred = align === "center";
|
2026-09-14 12:38:05 +02:00
|
|
|
const display = size === "display" || titleAs === "h1";
|
2026-09-11 02:46:56 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<header
|
|
|
|
|
className={cx(
|
|
|
|
|
"flex flex-col gap-sm",
|
|
|
|
|
centred ? "items-center text-center" : "items-start text-start",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{eyebrow ? (
|
|
|
|
|
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<Title
|
|
|
|
|
id={id}
|
|
|
|
|
className={cx(
|
|
|
|
|
"text-[var(--text-primary)]",
|
2026-09-14 12:38:05 +02:00
|
|
|
display ? "heading-h1 md:display-sm" : "heading-h2",
|
2026-09-11 02:46:56 +02:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{title}
|
|
|
|
|
</Title>
|
|
|
|
|
|
|
|
|
|
{lead ? (
|
|
|
|
|
<p className="body-lg max-w-[var(--layout-prose-max)] text-[var(--text-secondary)]">
|
|
|
|
|
{lead}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
</header>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
HERO
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
2026-09-14 12:38:05 +02:00
|
|
|
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";
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
export interface PageHeroProps {
|
|
|
|
|
title: string;
|
|
|
|
|
eyebrow?: string;
|
|
|
|
|
lead?: string;
|
2026-09-14 12:38:05 +02:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-09-11 02:46:56 +02:00
|
|
|
/** Buttons, rendered inline-end of the headline on wide viewports. */
|
|
|
|
|
actions?: ReactNode;
|
|
|
|
|
/** Wide picture below the headline row, as in Figma 253:4800. */
|
2026-09-14 12:38:05 +02:00
|
|
|
media?: PageHeroMedia;
|
|
|
|
|
mediaTreatment?: PageHeroMediaTreatment;
|
2026-09-11 02:46:56 +02:00
|
|
|
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,
|
2026-09-14 12:38:05 +02:00
|
|
|
wordmark,
|
2026-09-11 02:46:56 +02:00
|
|
|
actions,
|
|
|
|
|
media,
|
2026-09-14 12:38:05 +02:00
|
|
|
mediaTreatment = "rounded",
|
2026-09-11 02:46:56 +02:00
|
|
|
className,
|
|
|
|
|
}: PageHeroProps) {
|
|
|
|
|
return (
|
2026-09-14 12:38:05 +02:00
|
|
|
<section className={cx("pt-2xl md:pt-3xl", className)}>
|
2026-09-11 02:46:56 +02:00
|
|
|
<Container className="flex flex-col gap-2xl">
|
2026-09-14 12:38:05 +02:00
|
|
|
{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. */
|
|
|
|
|
<img
|
|
|
|
|
src={wordmark.src}
|
|
|
|
|
alt={wordmark.alt}
|
|
|
|
|
width={wordmark.width}
|
|
|
|
|
height={wordmark.height}
|
|
|
|
|
loading="eager"
|
|
|
|
|
decoding="async"
|
|
|
|
|
draggable={false}
|
|
|
|
|
style={{ maxWidth: `${wordmark.drawnWidth}px` }}
|
|
|
|
|
className="block h-auto w-full"
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-09-11 02:46:56 +02:00
|
|
|
<div className="flex flex-col gap-lg lg:flex-row lg:items-end lg:justify-between">
|
|
|
|
|
<div className="flex max-w-[46rem] flex-col gap-sm">
|
|
|
|
|
{eyebrow ? (
|
|
|
|
|
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<h1 className="heading-h1 text-[var(--text-primary)] md:display-sm">
|
|
|
|
|
{title}
|
|
|
|
|
</h1>
|
|
|
|
|
|
|
|
|
|
{lead ? (
|
|
|
|
|
<p className="body-lg max-w-[var(--layout-prose-max)] text-[var(--text-secondary)]">
|
|
|
|
|
{lead}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{actions ? (
|
2026-09-14 12:38:05 +02:00
|
|
|
<div className="flex flex-wrap items-center gap-lg lg:justify-end">
|
2026-09-11 02:46:56 +02:00
|
|
|
{actions}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{media ? (
|
2026-09-14 12:38:05 +02:00
|
|
|
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. */
|
|
|
|
|
<div className="relative w-full overflow-hidden">
|
|
|
|
|
<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/425]"
|
|
|
|
|
/>
|
|
|
|
|
<span
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
className="pointer-events-none absolute inset-0 bg-[var(--overlay-picture-shade)]"
|
|
|
|
|
/>
|
|
|
|
|
<span
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
className="pointer-events-none absolute inset-0 bg-[var(--overlay-picture-tint)]"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<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 max-w-full rounded-surface object-cover"
|
|
|
|
|
/>
|
|
|
|
|
)
|
2026-09-11 02:46:56 +02:00
|
|
|
) : null}
|
|
|
|
|
</Container>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
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 (
|
|
|
|
|
<div
|
|
|
|
|
className={cx(
|
|
|
|
|
"flex h-full flex-col gap-xs rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-lg",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{eyebrow ? (
|
|
|
|
|
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
|
|
|
|
|
) : null}
|
|
|
|
|
<Title className="body-lg-bold text-[var(--text-primary)]">{title}</Title>
|
|
|
|
|
{children ? (
|
|
|
|
|
<div className="body-md text-[var(--text-secondary)]">{children}</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<div
|
|
|
|
|
className={cx(
|
|
|
|
|
"flex flex-col items-start gap-sm rounded-card border border-dashed border-[var(--border-default)] bg-[var(--surface-sunken)] p-lg text-start",
|
|
|
|
|
className,
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{title ? (
|
|
|
|
|
<p className="body-md-bold text-[var(--text-primary)]">{title}</p>
|
|
|
|
|
) : null}
|
|
|
|
|
<p className="body-md text-[var(--text-secondary)]">{message}</p>
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
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 (
|
|
|
|
|
<div className={cx("flex flex-col gap-sm", className)}>
|
|
|
|
|
{label ? (
|
|
|
|
|
<p className="label-md uppercase text-[var(--text-tertiary)]">{label}</p>
|
|
|
|
|
) : null}
|
|
|
|
|
<ul className="flex flex-wrap gap-sm">
|
|
|
|
|
{values.map((value) => (
|
|
|
|
|
<li
|
|
|
|
|
key={value}
|
|
|
|
|
className="rounded-pill border border-[var(--border-accent)] bg-[var(--surface-accent-subtle)] px-md py-2xs body-sm-bold text-[var(--text-primary)]"
|
|
|
|
|
>
|
|
|
|
|
{value}
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|