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

453 lines
14 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";
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>
);
}