763 lines
25 KiB
TypeScript
763 lines
25 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;
|
|
/** 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,
|
|
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;
|
|
|
|
/* 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 (
|
|
<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">
|
|
<div className="flex flex-col gap-lg">
|
|
{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}
|
|
|
|
{leadWithActions ? (
|
|
<>
|
|
{/* Headline across the top ... */}
|
|
<div className="flex max-w-[43.75rem] flex-col gap-sm">
|
|
{eyebrow ? (
|
|
<p
|
|
className={cx(
|
|
"label-md uppercase text-[var(--text-accent)]",
|
|
rise(3),
|
|
)}
|
|
>
|
|
{eyebrow}
|
|
</p>
|
|
) : null}
|
|
|
|
{/* 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>
|
|
|
|
{/* ... 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>
|
|
)}
|
|
</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">
|
|
{/* `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="sh-pan 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)]"
|
|
/>
|
|
</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-[#20201E]" />
|
|
<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>
|
|
);
|
|
}
|