Files
sharper.ly/apps/web/app/routes/_page-kit/sections.tsx
T
IllusionandClaude Opus 5 2e920a96e7 Land the third design drop, seed the CMS, and retire the figures band
Three pieces of one session's work. They are in one commit because home.tsx
and _page-kit/copy.ts carry hunks from all three and splitting them by hand
would have meant committing intermediate states of those two files that were
never built or served.

THE THIRD DESIGN DROP

Only Home.dc.html and SiteFooter.dc.html differ from the second drop; the
other nine page drafts are byte-identical to what already ships.

The home page stops opening on a launch announcement dated to one day in
June. New headline, lead and primary call to action ("See open calls"), and
the launch band becomes "A nationwide project, delivered locally" -
launchEyebrow/Heading/Lead renamed approach*, LaunchShots renamed
GalleryShots, since the band is no longer about an event. Its Arabic reuses
the About page's already-approved wording for the coverage line and the body
heading; the new lead and headline are ar-draft like the rest of the Arabic
home copy and still want client sign-off.

Five motion classes in _page-kit/motion.css, tokens only, each with its RTL
form where the property has no logical one: sh-wipe (the headline uncovered
from the inline start), sh-pan (the hero picture on a view timeline), sh-zoom
(a picture closing in inside its frame), sh-rule-top (an accent rule drawn
across a hovered tile) and sh-marquee (the logo wall). sh-wipe fills
`backwards`, not `both`, so the clip is gone at rest - inset(0) would shave a
descender or an Arabic mark off a tight heading line - and it is removed
outright under prefers-reduced-motion, because a clip does not fade a
headline, it hides it.

The marquee carries each logo twice and slides exactly half the track, so its
spacing is a margin on the tile rather than a gap on the row: with a row gap
the seam lands half a gap short. The second half is aria-hidden with no link,
so the wall is neither read out nor tabbed through twice; it pauses on hover
and focus (WCAG 2.2.2) and unwraps to a plain row under reduced motion. Below
eight partners the wall stays a grid, because a half-track narrower than its
frame drifts a short row across empty space instead of looping.

SEEDING THE CMS

`pnpm seed` writes content through the document service. It opens no port, so
it runs happily beside `pnpm dev:cms`, and every write is matched on a slug
and written over the same document, so a second run changes nothing but the
fields the seed owns.

src/seed/approved.ts is transcribed from docs/ in both languages and is safe
to publish: site settings, the three consortium members, the topic taxonomy,
the five activity streams with their implementing partners, the launch
article in full, and the four pages. What docs/ withholds is not written -
no statistics, no project email, no Facebook URL, no consortium addresses,
all of which the source writes as bracketed placeholders. Two fields are
judgement calls and say so at the line: activityStatus, which the schema
requires and the source never states, and the launch article's year, which is
derived - the source says "Tuesday, June 30" and within December 2025 to
December 2027 that is 2026 and no other year.

src/seed/demo.ts is invented and kept apart three ways: opt-in behind --demo,
refused outright when NODE_ENV=production, and tagged in the data itself with
a demo- slug prefix that shows in every URL and is how --demo --clean finds
them again, with nothing remembered between runs.

Two things the Strapi 5 document service does not do by itself, both of which
leave Arabic rows behind that the next run then collides with on the unique
slug: delete({ documentId }) with no locale removes the DEFAULT locale only,
not the document, and findMany({ locale }) answers for one locale, so a
document whose English half has gone is invisible to a search in English. The
first --clean left six Arabic orphans and failed the next run partway.
deleteMatching now pools documentIds across both locales and deletes with
locale: '*'.

Knex aborts its pending pool acquisitions on teardown, and each abort rejects
a promise nothing awaits, so it arrives as an unhandled rejection rather than
as an error a try/catch would see. scripts/seed.js absorbs exactly that, for
the length of the shutdown only: a seed that worked must not report itself as
a seed that failed.

THE FIGURES BAND, AND THE BAND THAT REPLACED IT

The third draft deletes the figures band, so shared.stat and blocks.stats-grid
are deleted with it, home-page.stats is dropped, and blocks.stats-grid comes
out of all six dynamic zones. On the web side that takes CmsStat, StatsRow,
CountUpValue, StatStrip and Card.Stat - the last two were already dead, but
they existed only to draw figures. No approved document carries a number, so
there is now nowhere for one to be typed in, which is the point.

The "nationwide project, delivered locally" band was half editable and half
not: its pictures came from an image-gallery block down the Blocks dynamic
zone, and its words came from bundled fallback copy with no CMS field at all.
The new page.band-intro component (eyebrow, heading, lead, images) on
home-page.approach puts the whole band in one place in the admin. The page
still falls back to the bundled copy when the fields are empty, and still
reads an image-gallery block after the component's own images, so a gallery
built in Blocks keeps rendering.

VERIFIED

pnpm typecheck and both builds pass. Both rule greps are clean: no raw hex or
tier-1 token in components/ or routes/, no physical-direction utility in
apps/web/app.

Against a seeded CMS on the built server: / redirects 302 with
Vary: Accept-Language, and all ten sections plus the detail routes return 200
in both locales with lang/dir correct in the first bytes. The home page draws
the marquee from twelve partners, the hero pan, the three hovered card rules
and the status pill, and carries no figures band.

The seed is idempotent - two identical --demo runs give identical counts,
balanced across locales - and a clean/re-seed cycle leaves nothing behind.
The approach band was proved editable end to end: heading, eyebrow, lead and
images changed through the CMS all came through on /en while /ar stayed on
its own Arabic, then the seeded copy was restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:16:53 +02:00

776 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;
/* 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">
{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="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>
)}
{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)]"
/>
{/* 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>
);
}