An audit of all twelve draft files against the build turned up a set of mismatches, most of them in the tier-2 token layer rather than in any one component. Tier 1 is untouched throughout - only the meanings moved. SHAPE. The drafts draw three corners, not five: 2px for a tag, thumbnail, flag or checkbox, 4px for anything with a box, and fully round for a filter chip or status pill. Nothing in any draft is 8px or 12px. --radius-card and --radius-surface therefore come down to radius/sm, a --radius-chip is added for the 2px tier, and --radius-control-lg - a quirk of the Figma Button frame's ghost cells - is retired along with its four consumers. ELEVATION. A shadow is a fraction of the ground it falls on, and the ramp was tuned for a white page: on the drafts' near-black ground a 12-28% black is invisible. Tier 1 gains the two deep steps the drafts actually draw (--shadow-dark-lg/xl), and --elevation-card/-popover/-modal move into the per-surface blocks so the dark surface can take them while the light one keeps the soft ramp. A resting card now has no shadow at all - the drafts separate with a hairline - and the black is spent on what floats. CARDS. The hover was a white-24% hairline and a faint glow; the drafts turn the hairline accent green, lift the card 6px and land a deep black under it. The lift is pointer-only and is dropped under prefers-reduced-motion. GHOST IS A TEXT LINK. The drafts never fill a ghost control - every "All updates ->" is plain text that turns green while the label-to-arrow gap opens. The tokens carry no fill in any state and the variant opts out of the 3px lift, which also retires the WCAG workaround the old Figma hover forced (green label on a green fill, 1.40:1). NAVIGATION. A nav label rests in text/secondary and brightens to primary; the current section stays primary and is marked by the accent rule, not by green text. HERO STRIPE. The middle block was painted near-black against a comment claiming the redesign had dropped white; the drafts paint it #F8F9FA. Back to background/inverse, and to the drafts' 390 : 495 : 363 split. styles/README.md is updated to match, including a contrast table that still described --surface-raised as the retired #2e2e2c. Not changed, because each is a decision rather than a defect: the drafts set every eyebrow, tag and meta line in IBM Plex Mono at 10-11px with 1.2-1.4px tracking (a third typeface, and no Arabic counterpart); draft headings are weight 800, which needs a font file we do not have; and the EU badge statement stays at --text-primary, since the visibility lockup is contractual. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
786 lines
26 KiB
TypeScript
786 lines
26 KiB
TypeScript
/* ============================================================================
|
|
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 | 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;
|
|
/**
|
|
* 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 (
|
|
<section
|
|
id={id}
|
|
{...(surface ? { "data-surface": surface } : {})}
|
|
className={cx("py-3xl md:py-4xl", band && "sh-band", 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";
|
|
/**
|
|
* 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";
|
|
/** 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 (
|
|
<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)]",
|
|
display ? "heading-h1 md:display-sm" : "heading-h2",
|
|
)}
|
|
>
|
|
{title}
|
|
</Title>
|
|
|
|
{lead ? (
|
|
<p className="body-lg max-w-[var(--layout-prose-max)] text-[var(--text-secondary)]">
|
|
{lead}
|
|
</p>
|
|
) : null}
|
|
</header>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
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 <img> 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 (
|
|
<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"
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
dir="ltr"
|
|
style={{ maxWidth: `${wordmark.drawnWidth + suffix.drawnWidth}px` }}
|
|
className="flex w-full items-start"
|
|
>
|
|
<img
|
|
src={wordmark.src}
|
|
alt={wordmark.alt}
|
|
width={wordmark.width}
|
|
height={wordmark.height}
|
|
loading="eager"
|
|
decoding="async"
|
|
draggable={false}
|
|
style={{ flex: `${wordmark.drawnWidth} 1 0%` }}
|
|
className="block h-auto min-w-0"
|
|
/>
|
|
{/* The clipping box starts at the P's right edge, so the mark inside it
|
|
can slide out from behind the P (hero.css). */}
|
|
<span
|
|
style={{ flex: `${suffix.drawnWidth} 1 0%` }}
|
|
className="sh-hero-suffix block min-w-0"
|
|
>
|
|
<img
|
|
src={suffix.src}
|
|
alt=""
|
|
width={suffix.width}
|
|
height={suffix.height}
|
|
loading="eager"
|
|
decoding="async"
|
|
draggable={false}
|
|
className="sh-hero-suffix__mark block h-auto w-full"
|
|
/>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<section
|
|
className={cx(
|
|
"pt-[var(--layout-hero-top)] pb-[var(--layout-hero-bottom)]",
|
|
washes.length > 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). */
|
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10">
|
|
{washes.map((wash, index) => (
|
|
<span
|
|
key={`${wash.from}-${wash.offset}-${index}`}
|
|
className={cx(
|
|
"sh-glow absolute rounded-pill",
|
|
wash.tone === "danger"
|
|
? "bg-[radial-gradient(closest-side,var(--surface-danger-subtle),transparent)]"
|
|
: "bg-[radial-gradient(closest-side,var(--surface-accent-subtle),transparent)]",
|
|
)}
|
|
style={{
|
|
top: `${wash.top}px`,
|
|
width: `${wash.width}px`,
|
|
height: `${wash.height}px`,
|
|
...(wash.from === "end"
|
|
? { insetInlineEnd: wash.offset }
|
|
: { insetInlineStart: wash.offset }),
|
|
...(wash.durationSeconds
|
|
? { animationDuration: `${wash.durationSeconds}s` }
|
|
: {}),
|
|
...(wash.reverse ? { animationDirection: "reverse" } : {}),
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
<Container className="flex flex-col gap-2xl">
|
|
{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. */
|
|
<div className={cx("flex flex-col gap-lg", rise())}>
|
|
{funderBadge}
|
|
|
|
{wordmark ? <HeroWordmark wordmark={wordmark} /> : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{statusPill ? <div className={rise(2)}>{statusPill}</div> : null}
|
|
|
|
{leadWithActions ? (
|
|
<>
|
|
{/* Headline across the top ... */}
|
|
<div className={cx("flex max-w-[43.75rem] flex-col gap-sm", rise(3))}>
|
|
{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>
|
|
</div>
|
|
|
|
{/* ... then the lead and the buttons sharing a row, sitting on the
|
|
same baseline, as the home draft draws them. */}
|
|
{lead || actions ? (
|
|
<div
|
|
className={cx(
|
|
"flex flex-col gap-xl lg:flex-row lg:items-end lg:justify-between",
|
|
rise(4),
|
|
)}
|
|
>
|
|
{lead ? (
|
|
<p
|
|
className={cx(
|
|
"body-lg max-w-[40rem]",
|
|
leadTone === "primary"
|
|
? "text-[var(--text-primary)]"
|
|
: "text-[var(--text-secondary)]",
|
|
)}
|
|
>
|
|
{lead}
|
|
</p>
|
|
) : null}
|
|
|
|
{actions ? (
|
|
<div className="flex flex-wrap items-center gap-lg lg:justify-end">
|
|
{actions}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<div className="flex flex-col gap-lg lg:flex-row lg:items-end lg:justify-between">
|
|
<div className={cx("flex max-w-[46rem] flex-col gap-sm", rise(3))}>
|
|
{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={cx(
|
|
"body-lg max-w-[var(--layout-prose-max)]",
|
|
leadTone === "primary"
|
|
? "text-[var(--text-primary)]"
|
|
: "text-[var(--text-secondary)]",
|
|
)}
|
|
>
|
|
{lead}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
{actions ? (
|
|
<div
|
|
className={cx(
|
|
"flex flex-wrap items-center gap-lg lg:justify-end",
|
|
rise(4),
|
|
)}
|
|
>
|
|
{actions}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{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. */
|
|
<div className={cx("w-full", rise(5))}>
|
|
<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/415]"
|
|
/>
|
|
<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)]"
|
|
/>
|
|
{/* A highlight travelling across the picture - the draft's
|
|
`sheen`. Decorative, and it rides on top of both washes. */}
|
|
<span
|
|
aria-hidden="true"
|
|
className="sh-sheen pointer-events-none absolute inset-0 bg-[linear-gradient(100deg,transparent_35%,var(--overlay-veil)_50%,transparent_65%)]"
|
|
/>
|
|
</div>
|
|
|
|
{/* The colour band (478:16360, redrawn in the drafts' home
|
|
hero): three blocks flush under the picture, 6px tall, split
|
|
390 : 495 : 363 and carried as flex-grow ratios so the split
|
|
holds at every width instead of only at the drafts' 1244.
|
|
|
|
The fills are `background/accent`, `background/inverse` and
|
|
`background/danger` - the drafts paint the middle block
|
|
#F8F9FA, so the stripe reads green / white / red on the dark
|
|
hero and flips to green / near-black / red 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. */}
|
|
<div aria-hidden="true" className="sh-bar-grow flex h-[6px] w-full">
|
|
<span className="basis-0 grow-[390] bg-[var(--surface-accent)]" />
|
|
<span className="basis-0 grow-[495] bg-[var(--surface-inverse)]" />
|
|
<span className="basis-0 grow-[363] bg-[var(--surface-danger)]" />
|
|
</div>
|
|
</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"
|
|
/>
|
|
)
|
|
) : null}
|
|
|
|
{below}
|
|
</Container>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
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 (
|
|
<div
|
|
className={cx(
|
|
"flex h-full flex-col gap-xs rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-lg",
|
|
className,
|
|
)}
|
|
>
|
|
{eyebrow ? (
|
|
<p
|
|
className={cx(
|
|
"label-md uppercase text-[var(--text-accent)]",
|
|
eyebrowVariant === "chip" &&
|
|
"self-start rounded-control bg-[var(--surface-accent-subtle)] px-xs py-2xs",
|
|
)}
|
|
>
|
|
{eyebrow}
|
|
</p>
|
|
) : null}
|
|
<Title className="heading-h3 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;
|
|
/** 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 (
|
|
<div
|
|
className={cx(
|
|
"flex flex-col gap-sm",
|
|
centred && "items-center text-center",
|
|
className,
|
|
)}
|
|
>
|
|
{/* Accent, like every other eyebrow in the system (SectionHeader). */}
|
|
{label ? (
|
|
<p className="label-md uppercase text-[var(--text-accent)]">{label}</p>
|
|
) : null}
|
|
<ul className={cx("flex flex-wrap gap-sm", centred && "justify-center")}>
|
|
{values.map((value) => (
|
|
<li
|
|
key={value}
|
|
/* The drafts rest a chip as a plain outline on the band and only
|
|
fill it under the pointer, where it also takes the 3px lift
|
|
every other control has. */
|
|
className={cx(
|
|
"rounded-pill border border-[var(--border-default)] bg-transparent",
|
|
"px-md py-2xs body-sm-bold text-[var(--text-primary)]",
|
|
"transition duration-[var(--motion-duration-base)]",
|
|
"hover:-translate-y-[3px] hover:border-[var(--border-accent)]",
|
|
"hover:bg-[var(--surface-accent-subtle)]",
|
|
)}
|
|
>
|
|
{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>
|
|
);
|
|
}
|