Rebuild the site to the Claude Design drafts

Carries the 11 page drafts plus the shared SiteNav and SiteFooter onto the
project's own design system: tier-2 tokens, the published type roles, logical
properties throughout, and both locales.

Surfaces
- The page stands on #0B0B0A with #1D1D1B bands lifted off it, as the drafts
  do; the body previously painted the band colour, so every band was a step
  too light.
- A card is drawn one step off whatever it stands on (#1D1D1B on the ground,
  #141413 inside a band). Components paint --surface-card and a band re-points
  it for its subtree, so a card can never come out the same colour as its band.

Shell
- Nav: translucent ground with a blur, chevrons on group triggers, an accent
  rule under the current section, the drafts' compact dropdown card, and an
  EN | AR toggle in place of the globe menu.
- Footer: the drafts' four-column grid, one-line brand text, Facebook as a
  bordered button, the EU lockup, the implemented-by row and the funding
  disclaimer.

Pages
- Home: hero with counting figures, launch band, three areas of change,
  newsroom, FAQ accordion and the consortium band.
- The nine interior pages rebuilt and then audited section by section against
  their drafts.
- Applications: the three-step application form now posts real submissions to
  the Strapi `application` type, validated server-side and working without
  JavaScript.

Motion
- Every animation in the drafts, as classes in _page-kit/motion.css: staggered
  entrances, scroll-driven reveals, the drifting washes (each page carries its
  own geometry), the sheen, the counting figures and the accordion. The
  drafts' coloured hover glow is deliberately not shipped.

CMS
- New `faq` collection type behind the home page's accordion.
- `role` added to the consortium member component so an editor can supply the
  chip and the deliverable separately.

Content
- No invented figure, date, name or commitment ships: the five-working-day
  reply, the placeholder email and Facebook handle, the sample news, events,
  publications and FAQs, the invented application rules and the TBD counters
  are all withheld pending real content. Figures that survived (3 consortium
  partners, 4 areas of work, 16 human security sub-projects, Dec 2025 - Dec
  2027) are verified against docs/ and the bundled draft copy.

Verified: pnpm typecheck (web + CMS) and pnpm --filter web build pass; all 20
page/locale URLs render 200 through the built SSR handler, including with the
CMS unreachable; no raw hex or physical-direction utility in components or
routes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 20:32:56 +02:00
co-authored by Claude Opus 5
parent 8464c24fb1
commit aedef6daeb
63 changed files with 7276 additions and 1768 deletions
+294 -123
View File
@@ -4,11 +4,29 @@
URL /en/activities-and-initiatives · /ar/activities-and-initiatives
Route id "activities" (see app/routes.ts)
The page is organised by the five activity streams of the approved draft
copy (docs/SHARPER_Website_Draft_Section_EN_AR.docx, section 3): the stream
headings, descriptions and implementing partners are project structure and
live in ./activities.content, while the activities filed under each stream
come from Strapi.
Laid out to the 2026 redesign draft ("Activities-and-Initiatives"): an
ambient hero, a "filter by partner" pill row, and the five activity streams
as a numbered disclosure list - each row carrying its implementing partner
and opening onto the stream's description and the activities filed under it.
The five streams are the approved draft copy
(docs/SHARPER_Website_Draft_Section_EN_AR.docx, section 3): their headings,
descriptions and implementing partners are project structure and live in
./activities.content, while the activities themselves come from Strapi.
THE DISCLOSURE IS A NATIVE <details>, NOT THE PAGE KIT'S <Accordion>.
That component renders a markdown answer and nothing else; these panels
carry a grid of links to the activities in the stream, and those links have
to stay in the served HTML whether the panel is open or shut, or the only
route a crawler has to an activity page disappears. `<details>` keeps them
there and needs no JavaScript to work.
THE FIGURES BAND IS NOT BUILT. The draft draws three counters reading "TBD"
over a note saying the numbers must be confirmed before publication; the
approved copy says the same ("[XX+] activities completed · [XX+] CSOs
engaged · [X] regions covered - placeholder only"). Neither the figures nor
the placeholder is rendered (CLAUDE.md rule 5), and there is no
`activities-page` single type in the CMS to read a confirmed figure from.
THE FIELD NAMES BELOW ARE THE CMS'S OWN
apps/cms/src/api/activity/content-types/activity/schema.json:
@@ -16,15 +34,10 @@
`cover`), `implementingPartners` and `regions` as RELATIONS, and NO dates
or `location` string. Sorting or filtering on a field the content type does
not have is a 400 from Strapi, so nothing here is guessed.
The CMS is empty today, so the empty state is what actually renders: the
five streams still describe the work, and one localised panel explains that
individual activities are published as they are completed.
========================================================================= */
import { Link } from "react-router";
import { useSearchParams } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { getMessages, sectionPath, useTranslation } from "~/i18n";
import { DEFAULT_LOCALE, isLocale, type Locale } from "~/i18n/config";
@@ -39,6 +52,7 @@ import {
} from "~/lib/strapi";
import {
ACTIVITY_STREAMS,
ACTIVITY_STREAM_KEYS,
activitiesIndexPath,
activityPath,
@@ -49,11 +63,17 @@ import {
readRootMeta,
streamAnchorId,
toActivityStreamKey,
type ActivityStreamKey,
type ActivitySummary,
} from "./activities.content";
import { getRouteCopy } from "./_events-opportunities/copy";
import { FilterPills, ListingHero, PageShell } from "./_shared/listing-ui";
import { getListingChrome } from "./_shared/page-copy";
import type { Route } from "./+types/activities-and-initiatives";
import "./_page-kit/motion.css";
/* ---------------------------------------------------------------------------
CMS SHAPES
------------------------------------------------------------------------ */
@@ -109,6 +129,42 @@ function namesOf(entries: readonly NamedEntity[] | null | undefined): string | n
/** How many activities one request pulls. Well above the project's volume. */
const ACTIVITY_PAGE_SIZE = 100;
/* ---------------------------------------------------------------------------
THE PARTNER FILTER
----------------------------------------------------------------------------
The draft's pill row. The three consortium members are proper names, not
translatable copy - they are written the same way in the English and the
Arabic draft - and which stream each one implements is read off the approved
stream copy rather than restated here, so the filter cannot drift from the
line the panel shows.
------------------------------------------------------------------------ */
const PARTNER_FILTERS = ["acted", "uec", "sbe"] as const;
type PartnerFilter = (typeof PARTNER_FILTERS)[number];
const PARTNER_LABELS: Readonly<Record<PartnerFilter, string>> = {
acted: "Acted",
uec: "UEC",
sbe: "SBE",
};
function isPartnerFilter(value: unknown): value is PartnerFilter {
return (
typeof value === "string" &&
(PARTNER_FILTERS as readonly string[]).includes(value)
);
}
/** True when the stream's approved partner line names this organisation. */
function streamHasPartner(
stream: ActivityStreamKey,
partner: PartnerFilter,
): boolean {
return ACTIVITY_STREAMS[DEFAULT_LOCALE][stream].partners
.toLowerCase()
.includes(partner);
}
/* ---------------------------------------------------------------------------
LOADER
------------------------------------------------------------------------ */
@@ -154,17 +210,6 @@ function toSummary(entity: ActivityEntity): ActivitySummary {
};
}
/* DRAFT COPY NOTE - the source document ends section 3 with
"[XX+] activities completed · [XX+] CSOs engaged · [X] regions covered -
placeholder only: these figures are marked "TBD" in the official fact
sheet and must not be published until confirmed."
Nothing is hard-coded here and no placeholder is rendered. The figures
strip has also been removed rather than left pointing at an
`activities-page` single type that DOES NOT EXIST in the CMS (the single
types are about-page, contact-page, home-page, site-settings and
what-we-do-page): a request to /api/activities-page is a 404 on every page
load. Give the section a home in Strapi first, then bring the strip back. */
export async function loader({ params }: Route.LoaderArgs) {
// The ":lang" layout has already validated the segment; the guard is here so
// the locale is a `Locale` by inference rather than by assertion.
@@ -229,119 +274,245 @@ export default function ActivitiesAndInitiatives({
}: Route.ComponentProps) {
const { activities } = loaderData;
const { locale, t } = useTranslation();
const [searchParams] = useSearchParams();
const copy = getIndexCopy(locale);
const chrome = getListingChrome(locale).activities;
const routeCopy = getRouteCopy(locale);
const grouped = groupActivitiesByStream(activities);
const hasActivities = activities.length > 0;
const pathname = activitiesIndexPath(locale);
const requested = searchParams.get("partner");
const partner = isPartnerFilter(requested) ? requested : null;
const streams = ACTIVITY_STREAM_KEYS.filter(
(stream) => partner === null || streamHasPartner(stream, partner),
);
const pills = [
{
id: "all",
label: chrome.allPartners,
href: pathname,
isCurrent: partner === null,
},
...PARTNER_FILTERS.map((value) => ({
id: value,
label: PARTNER_LABELS[value],
href: `${pathname}?partner=${value}`,
isCurrent: partner === value,
})),
];
return (
<div className="mx-auto flex w-full max-w-[var(--layout-content-max)] flex-col gap-[var(--layout-section-gap)] px-[var(--layout-gutter)] py-4xl">
<header className="flex max-w-[var(--layout-prose-max)] flex-col gap-md">
<p className="label-md text-text-accent">{copy.eyebrow}</p>
<h1 className="display-sm text-text-primary">{copy.headline}</h1>
<p className="body-lg text-text-secondary">{copy.lede}</p>
</header>
<>
<ListingHero
eyebrow={copy.eyebrow}
heading={copy.headline}
intro={copy.lede}
ambient={[{ top: -250, from: "start", offset: "12%", width: 740, height: 580, durationSeconds: 21 }]}
/>
<nav aria-label={copy.streamsNavLabel}>
<h2 className="sr-only">{copy.streamsNavLabel}</h2>
<ul className="flex flex-wrap gap-sm">
{ACTIVITY_STREAM_KEYS.map((stream) => (
<li key={stream}>
<a
href={`#${streamAnchorId(stream)}`}
className="body-sm-bold inline-flex rounded-pill border border-border-subtle px-md py-xs text-text-secondary transition-colors duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)] hover:border-border-strong hover:text-text-primary"
>
{getStreamCopy(stream, locale).title}
</a>
</li>
))}
</ul>
</nav>
<PageShell>
{/* The draft reveals the filter row as it enters, like every other
band on the page. */}
<div className="sh-reveal">
<FilterPills label={chrome.partnerFilterLabel} pills={pills} />
</div>
{hasActivities ? null : (
<section
aria-labelledby="activities-empty"
className="flex flex-col items-start gap-md rounded-card border border-border-subtle bg-surface-raised p-xl"
>
<h2 id="activities-empty" className="body-lg-bold text-text-primary">
{copy.emptyTitle}
</h2>
<p className="body-md max-w-[var(--layout-prose-max)] text-text-secondary">
{copy.emptyBody}
</p>
<Button
as={Link}
to={sectionPath("events", locale)}
variant="secondary"
size="sm"
iconEnd={<ArrowIcon />}
>
{t("nav.sections.events")}
</Button>
</section>
)}
{ACTIVITY_STREAM_KEYS.map((stream) => {
const streamCopy = getStreamCopy(stream, locale);
const items = grouped[stream];
const anchor = streamAnchorId(stream);
return (
{hasActivities ? null : (
<section
key={stream}
id={anchor}
aria-labelledby={`${anchor}-title`}
className="flex scroll-mt-4xl flex-col gap-lg"
aria-labelledby="activities-empty"
className="flex flex-col items-start gap-md rounded-card border border-dashed border-border-default p-xl"
>
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-xs">
<h2 id={`${anchor}-title`} className="heading-h2 text-text-primary">
{streamCopy.title}
</h2>
<p className="body-md text-text-secondary">
{streamCopy.description}
</p>
<p className="body-sm text-text-tertiary">
<span className="body-sm-bold text-text-secondary">
{streamCopy.partnerLabel}:
</span>{" "}
{streamCopy.partners}
</p>
</div>
{items.length > 0 ? (
<>
<h3 className="sr-only">{copy.streamListLabel}</h3>
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
{items.map((activity) => (
<Card.Content
as="li"
key={activity.id}
titleAs="h4"
href={activityPath(activity.slug, locale)}
title={activity.title}
eyebrow={activityEyebrow(activity)}
excerpt={activity.summary ?? undefined}
actionLabel={t("actions.viewDetails")}
image={
activity.imageUrl
? { src: activity.imageUrl, alt: activity.imageAlt }
: undefined
}
/>
))}
</ul>
</>
) : hasActivities ? (
<p className="body-sm text-text-tertiary">{copy.streamEmpty}</p>
) : null}
<h2 id="activities-empty" className="body-lg-bold text-text-primary">
{copy.emptyTitle}
</h2>
<p className="body-md max-w-[var(--layout-prose-max)] text-text-secondary">
{copy.emptyBody}
</p>
</section>
);
})}
</div>
)}
<section aria-label={copy.streamsNavLabel} className="flex flex-col">
<h2 className="sr-only">{copy.streamsNavLabel}</h2>
<div className="border-t border-border-default">
{streams.map((stream, index) => (
<StreamDisclosure
key={stream}
stream={stream}
ordinal={index + 1}
open={index === 0}
activities={grouped[stream]}
hasActivities={hasActivities}
locale={locale}
viewDetailsLabel={t("actions.viewDetails")}
streamEmptyLabel={copy.streamEmpty}
streamListLabel={copy.streamListLabel}
/>
))}
</div>
</section>
{/* Where to go next - the draft's two closing cards. Both blurbs are
the approved standfirsts of sections 4 and 8, not new copy. */}
<nav aria-label={chrome.nextStepsLabel}>
<h2 className="sr-only">{chrome.nextStepsLabel}</h2>
<ul className="grid gap-lg sm:grid-cols-2">
<Card.Content
as="li"
titleAs="h3"
className="sh-reveal sh-reveal--in"
href={sectionPath("events", locale)}
eyebrow={chrome.nextEventsEyebrow}
title={routeCopy.events.heading}
excerpt={routeCopy.events.intro}
actionLabel={t("actions.viewUpcomingSessions")}
/>
<Card.Content
as="li"
titleAs="h3"
className="sh-reveal sh-reveal--in"
href={sectionPath("applications", locale)}
eyebrow={chrome.nextApplicationsEyebrow}
title={routeCopy.opportunities.heading}
excerpt={routeCopy.opportunities.intro}
actionLabel={t("actions.seeCurrentCalls")}
/>
</ul>
</nav>
</PageShell>
</>
);
}
/** The card kicker: where the activity runs. An activity carries no dates. */
function activityEyebrow(activity: ActivitySummary): string | undefined {
return activity.location ?? undefined;
/* ---------------------------------------------------------------------------
ONE STREAM
------------------------------------------------------------------------ */
/** The +/- mark, drawn the way the page kit's accordion draws it. */
function ToggleMark() {
return (
<span
aria-hidden="true"
className={[
"flex size-8 shrink-0 items-center justify-center rounded-pill border",
"border-border-default text-text-accent transition-colors",
"duration-[var(--motion-duration-base)]",
"group-open:border-border-accent group-open:bg-surface-accent-subtle",
].join(" ")}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M1 7h12" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path
d="M7 1v12"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
className="origin-center transition-transform duration-[var(--motion-duration-base)] group-open:scale-y-0"
/>
</svg>
</span>
);
}
function StreamDisclosure({
stream,
ordinal,
open,
activities,
hasActivities,
locale,
viewDetailsLabel,
streamEmptyLabel,
streamListLabel,
}: {
stream: ActivityStreamKey;
ordinal: number;
open: boolean;
activities: readonly ActivitySummary[];
hasActivities: boolean;
locale: Locale;
viewDetailsLabel: string;
streamEmptyLabel: string;
streamListLabel: string;
}) {
const copy = getStreamCopy(stream, locale);
const anchor = streamAnchorId(stream);
const number = new Intl.NumberFormat(`${locale}-u-nu-latn`, {
minimumIntegerDigits: 2,
useGrouping: false,
}).format(ordinal);
return (
<details
id={anchor}
open={open}
className="sh-reveal sh-lift group scroll-mt-2xl border-b border-border-default hover:bg-surface-raised"
>
<summary
className={[
"flex cursor-pointer list-none items-center gap-lg py-lg",
"text-start text-text-primary transition-colors",
"duration-[var(--motion-duration-base)] hover:text-text-accent",
"[&::-webkit-details-marker]:hidden",
].join(" ")}
>
<span aria-hidden="true" className="label-md min-w-8 text-text-tertiary">
{number}
</span>
<h3 className="flex-1 heading-h3">{copy.title}</h3>
<span className="hidden shrink-0 rounded-[var(--radius-control)] bg-surface-accent-subtle px-xs py-2xs label-md uppercase text-text-accent sm:inline-block">
{copy.partners}
</span>
<ToggleMark />
</summary>
{/* The animation is attached by the `open` state, so it plays when the
reader opens the row rather than once on first paint. */}
<div className="flex flex-col gap-lg pb-xl ps-2xl group-open:[animation:sh-answer_var(--motion-duration-base)_var(--motion-ease-entrance)_both]">
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-xs">
<p className="body-md text-text-secondary">{copy.description}</p>
<p className="body-sm text-text-tertiary">
<span className="body-sm-bold text-text-secondary">
{copy.partnerLabel}:
</span>{" "}
{copy.partners}
</p>
</div>
{activities.length > 0 ? (
<>
<h4 className="sr-only">{streamListLabel}</h4>
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
{activities.map((activity) => (
<Card.Content
as="li"
key={activity.id}
titleAs="h4"
href={activityPath(activity.slug, locale)}
title={activity.title}
eyebrow={activity.location ?? undefined}
excerpt={activity.summary ?? undefined}
actionLabel={viewDetailsLabel}
image={
activity.imageUrl
? { src: activity.imageUrl, alt: activity.imageAlt }
: undefined
}
/>
))}
</ul>
</>
) : hasActivities ? (
<p className="body-sm text-text-tertiary">{streamEmptyLabel}</p>
) : null}
</div>
</details>
);
}