Files
sharper.ly/apps/web/app/routes/_page-kit/sections.tsx
T

786 lines
26 KiB
TypeScript
Raw Normal View History

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";
import "./hero.css";
import "./motion.css";
2026-09-11 02:46:56 +02:00
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;
2026-09-11 02:46:56 +02:00
/** 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;
2026-09-11 02:46:56 +02:00
}
/**
* 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,
2026-09-11 02:46:56 +02:00
className,
bleed = false,
...rest
}: PageSectionProps) {
return (
<section
id={id}
{...(surface ? { "data-surface": surface } : {})}
className={cx("py-3xl md:py-4xl", band && "sh-band", className)}
2026-09-11 02:46:56 +02:00
{...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 406. */
2026-09-14 12:38:05 +02:00
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;
2026-09-14 12:38:05 +02:00
}
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 406x86 wordmark 48px above the headline row).
2026-09-14 12:38:05 +02:00
*/
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;
2026-09-11 02:46:56 +02:00
/** 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;
2026-09-11 02:46:56 +02:00
/** Wide picture below the headline row, as in Figma 253:4800. */
2026-09-14 12:38:05 +02:00
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;
2026-09-14 12:38:05 +02:00
mediaTreatment?: PageHeroMediaTreatment;
2026-09-11 02:46:56 +02:00
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>
);
}
2026-09-11 02:46:56 +02:00
/**
* 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,
funderBadge,
statusPill,
2026-09-11 02:46:56 +02:00
actions,
leadWithActions = false,
ambient,
leadTone = "secondary",
reveal = false,
2026-09-11 02:46:56 +02:00
media,
2026-09-14 12:38:05 +02:00
mediaTreatment = "rounded",
below,
2026-09-11 02:46:56 +02:00
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 ?? [];
2026-09-11 02:46:56 +02:00
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}
2026-09-11 02:46:56 +02:00
<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>
2026-09-14 12:38:05 +02:00
) : null}
{statusPill ? <div className={rise(2)}>{statusPill}</div> : null}
2026-09-11 02:46:56 +02:00
{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}
2026-09-11 02:46:56 +02:00
<h1 className="heading-h1 text-[var(--text-primary)] md:display-sm">
{title}
</h1>
</div>
2026-09-11 02:46:56 +02:00
{/* ... 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}
2026-09-11 02:46:56 +02:00
</div>
{actions ? (
<div
className={cx(
"flex flex-wrap items-center gap-lg lg:justify-end",
rise(4),
)}
>
{actions}
</div>
) : null}
</div>
)}
2026-09-11 02:46:56 +02:00
{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={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>
2026-09-14 12:38:05 +02:00
</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}
{below}
2026-09-11 02:46:56 +02:00
</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";
2026-09-11 02:46:56 +02:00
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",
2026-09-11 02:46:56 +02:00
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",
2026-09-11 02:46:56 +02:00
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>
2026-09-11 02:46:56 +02:00
) : null}
<Title className="heading-h3 text-[var(--text-primary)]">{title}</Title>
2026-09-11 02:46:56 +02:00
{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";
2026-09-11 02:46:56 +02:00
className?: string;
}
/** The "What guides us" row: Dialogue · Community Leadership · Learning · … */
export function ValueChips({
values,
label,
align = "start",
className,
}: ValueChipsProps) {
2026-09-11 02:46:56 +02:00
if (values.length === 0) return null;
const centred = align === "center";
2026-09-11 02:46:56 +02:00
return (
<div
className={cx(
"flex flex-col gap-sm",
centred && "items-center text-center",
className,
)}
>
{/* Accent, like every other eyebrow in the system (SectionHeader). */}
2026-09-11 02:46:56 +02:00
{label ? (
<p className="label-md uppercase text-[var(--text-accent)]">{label}</p>
2026-09-11 02:46:56 +02:00
) : null}
<ul className={cx("flex flex-wrap gap-sm", centred && "justify-center")}>
2026-09-11 02:46:56 +02:00
{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)]",
)}
2026-09-11 02:46:56 +02:00
>
{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>
);
}