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
+503 -104
View File
@@ -1,49 +1,84 @@
/* ============================================================================
EVENTS CALENDAR - /{locale}/events-calendar
----------------------------------------------------------------------------
Draft section 4. The listing the draft describes:
Draft section 4, laid out to the 2026 redesign draft ("Events-Calendar"):
filter tabs All - Upcoming - Ongoing - Completed, plus a region filter
card fields title - date and time - format/location - registration
status - call to action (Register / Apply / Read recap)
filter row All · Upcoming · Ongoing · Completed as pills, a region
select beside them, and a List / Month view switch
list view one wide row per event: a date chip, the title, where and
when, the registration state, and the call to action
month view a seven-column grid with the events of one month in it
Every filter is a real URL (?status=&region=&page=), so a filtered calendar
is shareable, survives the back button and is reachable by a crawler that
only follows links. There is no client-side filtering to fall out of step
with the server, and the page works with scripting off.
Every control is a real URL (?status=&region=&view=&month=&page=), so a
filtered calendar is shareable, survives the back button and is reachable by
a crawler that only follows links. There is no client-side filtering to fall
out of step with the server, and the page works with scripting off.
Upcoming / Ongoing / Completed are DERIVED from the dates rather than stored
(see ./_events-opportunities/events.ts), so nothing goes stale the morning
after an event.
EMPTY BY DESIGN: the CMS is empty today, so the state that actually renders
is the localised "no events yet" panel - and if Strapi cannot be reached at
all, the page still renders, with a notice, instead of a 500.
NOT ONE DATE ON THIS PAGE IS WRITTEN HERE. The draft's five sample events -
the Tripoli SUQ dialogue on 4 March, the Benghazi network meeting, "March
2026" - were mockup content. The grid and the list are built from Strapi
entries; with an empty CMS the localised empty state renders instead, and if
Strapi cannot be reached at all the page still renders, with a notice,
rather than a 500.
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
* the "You're registered." confirmation panel - registration happens on
the event's own page, which already renders that approved confirmation;
putting it on the listing would mean faking a submission;
* "Add to calendar · iCal / Google" on the month header - the .ics route
is per event (events-calendar.$slug.ics.ts) and there is no whole-
calendar feed to offer, so the action lives on each event instead;
* the event-type kicker ("SUQ Dialogue", "Training"): `event.eventType`
exists in Strapi but is not carried on the card view model, which lives
in a module this change does not own.
========================================================================= */
import { Link, useSearchParams } from "react-router";
import { Button } from "~/components/ui/Button";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { CardLink, CardShell } from "~/components/ui/Card";
import {
intlLocale,
localizedPath,
sectionPath,
toIsoDate,
translate,
useTranslation,
type Locale,
} from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/events-calendar";
import {
collectRegions,
EVENT_STATUS_FILTERS,
eventActionKey,
eventRegistrationLabel,
eventTimeStatusKey,
EVENT_STATUS_FILTERS,
EVENT_TIME_ZONE,
filterEvents,
formatEventWhen,
sortEvents,
type EventCardData,
} from "./_events-opportunities/events";
import { fetchEvents } from "./_events-opportunities/events.server";
import { getRouteCopy } from "./_events-opportunities/copy";
import { readRootSeo } from "./_events-opportunities/root-seo";
import { EventCard, Notice } from "./_events-opportunities/ui";
import { Notice } from "./_events-opportunities/ui";
import {
buildCalendarMonth,
calendarDayIn,
monthKeyOf,
parseMonthKey,
weekdayLabel,
weekStartFor,
type CalendarDay,
type CalendarMonth,
} from "./_shared/calendar";
import {
buildPagination,
PAGE_SIZE,
@@ -54,16 +89,25 @@ import {
} from "./_shared/listing";
import {
EmptyState,
PageHeader,
FilterPills,
ListingHero,
MetaLine,
PageShell,
Pagination,
PillSelect,
ResultSummary,
TabLinks,
} from "./_shared/listing-ui";
import { getListingChrome } from "./_shared/page-copy";
import "./_page-kit/motion.css";
/** Longest region value we will echo back into a filter. */
const MAX_REGION_LENGTH = 80;
/** The two ways the calendar can be read. */
const VIEWS = ["list", "month"] as const;
type CalendarView = (typeof VIEWS)[number];
export async function loader({ params, request }: Route.LoaderArgs) {
const locale = requireLocale(params.lang);
const url = new URL(request.url);
@@ -75,6 +119,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
EVENT_STATUS_FILTERS,
"all",
);
const view = parseEnum(url.searchParams, "view", VIEWS, "list");
const { events, available } = await fetchEvents(locale, now);
@@ -98,6 +143,33 @@ export async function loader({ params, request }: Route.LoaderArgs) {
);
const start = (pagination.page - 1) * PAGE_SIZE;
/* Which month the grid opens on: the one the reader asked for, else the
month of the next event in the filtered set, else the current month.
Derived, never a literal - the draft's "March 2026" was sample content. */
const requestedMonth = parseMonthKey(url.searchParams.get("month"));
const firstDay = matching[0]
? calendarDayIn(matching[0].start, EVENT_TIME_ZONE)
: undefined;
const today = calendarDayIn(now, EVENT_TIME_ZONE);
const month =
requestedMonth ??
(firstDay ? monthKeyOf(firstDay) : monthKeyOf(today ?? "1970-01-01"));
const calendar = buildCalendarMonth({
month,
entries: matching.map((event) => ({
id: event.slug,
title: event.title,
href: `${sectionPath("events", locale)}/${encodeURIComponent(event.slug)}`,
start: event.start,
...(event.end ? { end: event.end } : {}),
allDay: event.allDay,
})),
timeZone: EVENT_TIME_ZONE,
weekStartsOn: weekStartFor(locale),
now,
});
return {
locale,
events: matching.slice(start, start + PAGE_SIZE),
@@ -105,6 +177,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
status,
region,
regions,
view,
calendar,
available,
/** Distinguishes "the CMS is empty" from "these filters match nothing". */
hasEvents: events.length > 0,
@@ -122,9 +196,14 @@ export function meta({ location, matches }: Route.MetaArgs) {
siteUrl: root.siteUrl,
title: translate(root.locale, "nav.sections.events"),
description: copy.events.metaDescription,
// A filtered or paginated view is the same content in another order.
// A filtered, paginated or re-viewed calendar is the same content in
// another order.
noIndex:
search.has("status") || search.has("region") || search.has("page"),
search.has("status") ||
search.has("region") ||
search.has("page") ||
search.has("view") ||
search.has("month"),
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
{
name: translate(root.locale, "nav.sections.home"),
@@ -139,115 +218,435 @@ export function meta({ location, matches }: Route.MetaArgs) {
}
export default function EventsCalendar({ loaderData }: Route.ComponentProps) {
const { events, pagination, status, region, regions, available, hasEvents } =
loaderData;
const {
events,
pagination,
status,
region,
regions,
view,
calendar,
available,
hasEvents,
} = loaderData;
const { locale, t } = useTranslation();
const copy = getRouteCopy(locale);
const chrome = getListingChrome(locale).events;
const [searchParams] = useSearchParams();
const pathname = sectionPath("events", locale);
const href = (key: string, value: string | undefined) =>
`${pathname}${withParam(searchParams, key, value)}`;
const tabs = EVENT_STATUS_FILTERS.map((value) => ({
const statusPills = EVENT_STATUS_FILTERS.map((value) => ({
id: value,
label: value === "all" ? t("filters.all") : t(eventTimeStatusKey(value)),
href: href("status", value === "all" ? undefined : value),
isCurrent: status === value,
}));
/* Paging and the month on show are properties of one view, so switching
view keeps the filters and starts the new view at its own beginning. */
const viewParams = new URLSearchParams();
if (status !== "all") viewParams.set("status", status);
if (region) viewParams.set("region", region);
const viewPills = VIEWS.map((value) => ({
id: value,
label: value === "list" ? chrome.viewList : chrome.viewMonth,
href: `${pathname}${withParam(
viewParams,
"view",
value === "list" ? undefined : value,
)}`,
isCurrent: view === value,
}));
/* What the region form has to carry across a GET submit - the query string
is replaced wholesale, so anything not in the form is lost. */
const keep: (readonly [string, string])[] = [
...(status === "all" ? [] : [["status", status] as const]),
...(view === "list" ? [] : [["view", view] as const]),
];
const isFiltered = status !== "all" || region !== null;
return (
<PageShell>
<PageHeader heading={copy.events.heading} intro={copy.events.intro} />
<>
<ListingHero
eyebrow={chrome.eyebrow}
heading={copy.events.heading}
intro={copy.events.intro}
ambient={[{ top: -250, from: "end", offset: "4%", width: 720, height: 560, durationSeconds: 20 }]}
/>
{available ? null : (
<Notice tone="info" role="status">
{copy.events.unavailable}
</Notice>
)}
<PageShell>
{available ? null : (
<Notice tone="info" role="status">
{copy.events.unavailable}
</Notice>
)}
<div className="flex flex-col gap-lg">
<TabLinks label={copy.events.statusFilterLabel} tabs={tabs} />
<div className="flex flex-col gap-lg">
<div className="flex flex-wrap items-center justify-between gap-lg border-b border-border-subtle pb-lg">
<FilterPills
label={copy.events.statusFilterLabel}
pills={statusPills}
>
{regions.length > 0 ? (
<PillSelect
name="region"
label={t("filters.region")}
value={region ?? ""}
options={regions.map((value) => ({ value, label: value }))}
allLabel={t("filters.all")}
applyLabel={t("filters.apply")}
action={pathname}
keep={keep}
/>
) : null}
</FilterPills>
{regions.length > 0 ? (
<nav aria-label={t("filters.region")} className="flex flex-col gap-xs">
<p className="label-md uppercase text-text-tertiary">
{t("filters.region")}
</p>
<ul className="flex flex-wrap items-center gap-xs">
<li>
<Button
as={Link}
to={href("region", undefined)}
prefetch="intent"
variant={region === null ? "secondary" : "ghost"}
size="sm"
aria-current={region === null ? "true" : undefined}
>
{t("filters.all")}
</Button>
</li>
{regions.map((value) => (
<li key={value}>
<Button
as={Link}
to={href("region", value)}
prefetch="intent"
variant={region === value ? "secondary" : "ghost"}
size="sm"
aria-current={region === value ? "true" : undefined}
>
{value}
</Button>
</li>
{/* The draft draws the List / Month switch as a pair of square
controls with no caption, beside the rounded filter pills. */}
<FilterPills
label={chrome.viewLabel}
pills={viewPills}
shape="control"
hideLabel
/>
</div>
{view === "list" ? <ResultSummary pagination={pagination} t={t} /> : null}
</div>
{view === "month" ? (
<MonthView
calendar={calendar}
locale={locale}
emptyMessage={chrome.monthEmpty}
label={chrome.calendarLabel}
previousLabel={chrome.previousMonth}
nextLabel={chrome.nextMonth}
hrefFor={(month) => href("month", month)}
/>
) : events.length > 0 ? (
<>
{/* The list is the page's content, so it gets a heading in the
outline even though the hero already names the page. */}
<h2 className="sr-only">{copy.events.listLabel}</h2>
<ul
aria-label={copy.events.listLabel}
className="flex flex-col gap-md"
>
{events.map((event) => (
<EventRow
key={event.slug}
event={event}
href={`${pathname}/${encodeURIComponent(event.slug)}`}
/>
))}
</ul>
</nav>
) : null}
<ResultSummary pagination={pagination} t={t} />
</div>
{events.length > 0 ? (
<>
<ul
aria-label={copy.events.listLabel}
className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3"
<Pagination
model={pagination}
pathname={pathname}
params={searchParams}
t={t}
/>
</>
) : hasEvents && isFiltered ? (
<EmptyState
title={t("filters.noResults")}
body={t("filters.noResultsHint")}
>
{events.map((event) => (
<EventCard
key={event.slug}
as="li"
event={event}
href={`${pathname}/${encodeURIComponent(event.slug)}`}
/>
))}
</ul>
<Pagination
model={pagination}
pathname={pathname}
params={searchParams}
t={t}
/>
</>
) : hasEvents && isFiltered ? (
<EmptyState
title={t("filters.noResults")}
body={t("filters.noResultsHint")}
>
<Button as={Link} to={pathname} variant="secondary" size="sm">
{t("filters.clear")}
</Button>
</EmptyState>
) : (
<EmptyState
title={t("events.noEvents")}
body={copy.events.emptyHint}
/>
)}
</PageShell>
<Button as={Link} to={pathname} variant="secondary" size="sm">
{t("filters.clear")}
</Button>
</EmptyState>
) : (
<EmptyState title={t("events.noEvents")} body={copy.events.emptyHint} />
)}
</PageShell>
</>
);
}
/* ---------------------------------------------------------------------------
ONE EVENT, AS THE DRAFT DRAWS IT
------------------------------------------------------------------------ */
/** "10:00–14:00" in Libya time, or nothing for an all-day event. */
function formatClock(event: EventCardData, locale: Locale): string | undefined {
if (event.allDay) return undefined;
const options: Intl.DateTimeFormatOptions = {
hour: "2-digit",
minute: "2-digit",
timeZone: EVENT_TIME_ZONE,
};
const formatter = new Intl.DateTimeFormat(intlLocale(locale), options);
const start = new Date(event.start);
if (Number.isNaN(start.getTime())) return undefined;
const end = event.end ? new Date(event.end) : null;
if (end && !Number.isNaN(end.getTime()) && end.getTime() !== start.getTime()) {
return formatter.formatRange(start, end);
}
return formatter.format(start);
}
/** The date block on the inline-start edge: month over the day number. */
function DateChip({ event, locale }: { event: EventCardData; locale: Locale }) {
const timeZone = event.allDay ? "UTC" : EVENT_TIME_ZONE;
const date = new Date(event.start);
if (Number.isNaN(date.getTime())) return null;
const part = (options: Intl.DateTimeFormatOptions) =>
new Intl.DateTimeFormat(intlLocale(locale), { ...options, timeZone }).format(
date,
);
return (
/* The draft draws a 76x82 block holding the month over the day and
nothing else - the full date, year included, is on the line under the
title, so repeating it here only crowds the block. */
<div className="flex h-[5.125rem] w-[4.75rem] shrink-0 flex-col items-center justify-center gap-2xs rounded-card border border-border-subtle bg-surface-sunken">
<span className="label-md uppercase text-text-accent">
{part({ month: "short" })}
</span>
<span className="heading-h2 text-text-primary">
{part({ day: "numeric" })}
</span>
</div>
);
}
function EventRow({ event, href }: { event: EventCardData; href: string }) {
const { locale, t } = useTranslation();
const copy = getRouteCopy(locale);
const isPast = event.timeStatus === "completed";
const isLive = !isPast && event.registrationStatus === "open";
const formatLabel =
event.format === "online"
? t("events.online")
: event.format === "hybrid"
? copy.events.formatHybrid
: event.format === "in-person"
? t("events.inPerson")
: undefined;
const clock = formatClock(event, locale);
/* The draft's kicker above the title. Empty for an event the CMS left
unclassified ("other"), in which case only the time status shows. */
const typeLabel = event.eventType
? copy.events.eventTypes[event.eventType]
: undefined;
return (
<CardShell
as="li"
linked
className="sh-reveal sh-reveal--in flex-col gap-lg p-lg md:flex-row md:items-center"
>
<div className="flex min-w-0 flex-1 items-center gap-lg">
<DateChip event={event} locale={locale} />
<div className="flex min-w-0 flex-col gap-xs">
<p className="label-md uppercase text-text-tertiary">
{typeLabel ? (
<>
<span className="text-text-accent">{typeLabel}</span>
<span aria-hidden="true"> · </span>
</>
) : null}
{t(eventTimeStatusKey(event.timeStatus))}
</p>
<h3 className="sh-card-title body-lg-bold text-text-primary">
<CardLink href={href} className="sh-card-link">
{event.title}
</CardLink>
</h3>
<p className="body-sm-bold text-text-secondary">
<time dateTime={toIsoDate(event.start)}>
{formatEventWhen(event, locale)}
</time>
</p>
<MetaLine
items={[formatLabel, event.place, event.region, clock].filter(
(item): item is string => Boolean(item),
)}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-md md:justify-end">
{/* A finished event's registration state is history, not news. */}
{isPast ? null : (
<span className="inline-flex items-center gap-xs rounded-pill border border-border-default px-md py-2xs label-md uppercase text-text-primary">
{isLive ? (
<span
aria-hidden="true"
className="sh-pulse size-[7px] shrink-0 rounded-pill bg-[var(--surface-accent)]"
/>
) : null}
{eventRegistrationLabel(event.registrationStatus, locale)}
</span>
)}
{/* The draft ends every row with the filled accent action - Register,
Apply, Join waitlist, Read recap - not a text hint. `sh-card-above`
lifts it over the stretched title link so it stays its own target,
and the label comes from the event's own registration state. */}
<Button
as={Link}
to={href}
prefetch="intent"
variant="primary"
size="md"
iconEnd={<ArrowIcon />}
className="sh-card-above shrink-0"
>
{t(eventActionKey(event))}
</Button>
</div>
</CardShell>
);
}
/* ---------------------------------------------------------------------------
THE MONTH GRID
------------------------------------------------------------------------ */
function MonthView({
calendar,
locale,
label,
emptyMessage,
previousLabel,
nextLabel,
hrefFor,
}: {
calendar: CalendarMonth;
locale: Locale;
label: string;
emptyMessage: string;
previousLabel: string;
nextLabel: string;
hrefFor: (month: string) => string;
}) {
const monthName = new Intl.DateTimeFormat(intlLocale(locale), {
month: "long",
year: "numeric",
timeZone: "UTC",
}).format(new Date(`${calendar.firstDay}T00:00:00Z`));
return (
<section aria-label={label} className="flex flex-col gap-lg">
<div className="flex flex-wrap items-center justify-between gap-md">
<h2 className="heading-h3 text-text-primary">{monthName}</h2>
<div className="flex items-center gap-xs">
<Button
as={Link}
to={hrefFor(calendar.previous)}
prefetch="intent"
variant="ghost"
size="sm"
iconStart={<ArrowIcon direction="backward" />}
>
{previousLabel}
</Button>
<Button
as={Link}
to={hrefFor(calendar.next)}
prefetch="intent"
variant="ghost"
size="sm"
iconEnd={<ArrowIcon />}
>
{nextLabel}
</Button>
</div>
</div>
{/* The hairlines are the grid's own gap showing through, which is how the
draft draws it - one border round the whole block, one pixel between
the cells. */}
<div className="grid grid-cols-7 gap-px overflow-hidden rounded-card border border-border-subtle bg-[var(--border-subtle)]">
{calendar.weekdays.map((weekday) => (
<div
key={weekday}
className="bg-surface-raised p-sm text-center label-md uppercase text-text-tertiary"
>
<span aria-hidden="true">
{weekdayLabel(weekday, intlLocale(locale), "narrow")}
</span>
<span className="sr-only">
{weekdayLabel(weekday, intlLocale(locale), "long")}
</span>
</div>
))}
{calendar.days.map((day) => (
<MonthCell key={day.key} day={day} locale={locale} />
))}
</div>
{calendar.entryCount === 0 ? (
<p className="body-md text-text-secondary" role="status">
{emptyMessage}
</p>
) : null}
</section>
);
}
function MonthCell({ day, locale }: { day: CalendarDay; locale: Locale }) {
const dayNumber = new Intl.NumberFormat(intlLocale(locale), {
useGrouping: false,
}).format(day.dayNumber);
return (
<div
className={[
"sh-lift flex min-h-[6rem] flex-col gap-2xs p-xs",
day.inMonth ? "bg-[var(--surface-page)]" : "bg-surface-sunken",
"hover:bg-surface-muted",
].join(" ")}
>
<p
className={[
"label-md",
day.isToday ? "text-text-accent" : "text-text-tertiary",
day.inMonth ? "" : "opacity-60",
].join(" ")}
>
<time dateTime={day.key}>{dayNumber}</time>
</p>
{day.entries.map((entry) => (
<Link
key={`${day.key}-${entry.id}`}
to={entry.href}
prefetch="intent"
className={[
"rounded-[var(--radius-control)] border-s-2 border-s-[var(--border-accent)]",
"bg-surface-accent-subtle px-2xs py-2xs body-sm-bold text-text-primary",
"transition-colors duration-[var(--motion-duration-fast)]",
"ease-[var(--motion-ease-standard)] hover:text-text-accent",
].join(" ")}
>
{entry.title}
</Link>
))}
</div>
);
}