An audit of all twelve draft files against the build turned up a set of mismatches, most of them in the tier-2 token layer rather than in any one component. Tier 1 is untouched throughout - only the meanings moved. SHAPE. The drafts draw three corners, not five: 2px for a tag, thumbnail, flag or checkbox, 4px for anything with a box, and fully round for a filter chip or status pill. Nothing in any draft is 8px or 12px. --radius-card and --radius-surface therefore come down to radius/sm, a --radius-chip is added for the 2px tier, and --radius-control-lg - a quirk of the Figma Button frame's ghost cells - is retired along with its four consumers. ELEVATION. A shadow is a fraction of the ground it falls on, and the ramp was tuned for a white page: on the drafts' near-black ground a 12-28% black is invisible. Tier 1 gains the two deep steps the drafts actually draw (--shadow-dark-lg/xl), and --elevation-card/-popover/-modal move into the per-surface blocks so the dark surface can take them while the light one keeps the soft ramp. A resting card now has no shadow at all - the drafts separate with a hairline - and the black is spent on what floats. CARDS. The hover was a white-24% hairline and a faint glow; the drafts turn the hairline accent green, lift the card 6px and land a deep black under it. The lift is pointer-only and is dropped under prefers-reduced-motion. GHOST IS A TEXT LINK. The drafts never fill a ghost control - every "All updates ->" is plain text that turns green while the label-to-arrow gap opens. The tokens carry no fill in any state and the variant opts out of the 3px lift, which also retires the WCAG workaround the old Figma hover forced (green label on a green fill, 1.40:1). NAVIGATION. A nav label rests in text/secondary and brightens to primary; the current section stays primary and is marked by the accent rule, not by green text. HERO STRIPE. The middle block was painted near-black against a comment claiming the redesign had dropped white; the drafts paint it #F8F9FA. Back to background/inverse, and to the drafts' 390 : 495 : 363 split. styles/README.md is updated to match, including a contrast table that still described --surface-raised as the retired #2e2e2c. Not changed, because each is a decision rather than a defect: the drafts set every eyebrow, tag and meta line in IBM Plex Mono at 10-11px with 1.2-1.4px tracking (a third typeface, and no Arabic counterpart); draft headings are weight 800, which needs a font file we do not have; and the EU badge statement stays at --text-primary, since the visibility lockup is contractual. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
519 lines
18 KiB
TypeScript
519 lines
18 KiB
TypeScript
/* ============================================================================
|
|
ACTIVITIES AND INITIATIVES - the section index
|
|
----------------------------------------------------------------------------
|
|
URL /en/activities-and-initiatives · /ar/activities-and-initiatives
|
|
Route id "activities" (see app/routes.ts)
|
|
|
|
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:
|
|
`activityStream` (not `stream`), `activityStatus`, `coverImage` (not
|
|
`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.
|
|
========================================================================= */
|
|
|
|
import { useSearchParams } from "react-router";
|
|
|
|
import { Card } from "~/components/ui/Card";
|
|
import { getMessages, sectionPath, useTranslation } from "~/i18n";
|
|
import { DEFAULT_LOCALE, isLocale, type Locale } from "~/i18n/config";
|
|
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
|
import {
|
|
getMany,
|
|
isStrapiError,
|
|
mediaAlt,
|
|
mediaUrl,
|
|
type StrapiEntity,
|
|
type StrapiMedia,
|
|
} from "~/lib/strapi";
|
|
|
|
import {
|
|
ACTIVITY_STREAMS,
|
|
ACTIVITY_STREAM_KEYS,
|
|
activitiesIndexPath,
|
|
activityPath,
|
|
getIndexCopy,
|
|
getStreamCopy,
|
|
groupActivitiesByStream,
|
|
isActivityStatus,
|
|
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
|
|
------------------------------------------------------------------------ */
|
|
|
|
/** A related entry that only contributes its display name. */
|
|
interface NamedEntity {
|
|
readonly name?: string | null;
|
|
readonly slug?: string | null;
|
|
}
|
|
|
|
/** `api::activity.activity` - one activity or initiative. */
|
|
interface ActivityEntity extends StrapiEntity {
|
|
readonly title: string;
|
|
readonly slug: string;
|
|
readonly activityStream?: string | null;
|
|
readonly activityStatus?: string | null;
|
|
readonly summary?: string | null;
|
|
readonly regions?: readonly NamedEntity[] | null;
|
|
readonly implementingPartners?: readonly NamedEntity[] | null;
|
|
readonly coverImage?: StrapiMedia | null;
|
|
}
|
|
|
|
/** The fields one listing request asks for. All of them exist on the type. */
|
|
const ACTIVITY_FIELDS = [
|
|
"title",
|
|
"slug",
|
|
"activityStream",
|
|
"activityStatus",
|
|
"summary",
|
|
] as const;
|
|
|
|
const ACTIVITY_POPULATE = {
|
|
coverImage: { fields: ["url", "alternativeText"] },
|
|
regions: { fields: ["name", "slug"] },
|
|
implementingPartners: { fields: ["name", "slug"] },
|
|
} as const;
|
|
|
|
/**
|
|
* An activity carries no date in the CMS, so the newest CMS entry leads.
|
|
* `publishedAt` is a system field and is always sortable.
|
|
*/
|
|
const ACTIVITY_SORT = ["publishedAt:desc", "title:asc"] as const;
|
|
|
|
/** Names of a relation list, joined - or null when the relation is empty. */
|
|
function namesOf(entries: readonly NamedEntity[] | null | undefined): string | null {
|
|
const names = (entries ?? [])
|
|
.map((entry) => entry?.name?.trim())
|
|
.filter((name): name is string => Boolean(name));
|
|
|
|
return names.length > 0 ? names.join(", ") : null;
|
|
}
|
|
|
|
/** 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
|
|
------------------------------------------------------------------------ */
|
|
|
|
/**
|
|
* A CMS read that must never take the page down with it. An unreachable or
|
|
* not-yet-modelled Strapi degrades to the empty state - the five stream
|
|
* descriptions are still worth serving - and the reason is logged server-side.
|
|
* Anything that is not a Strapi transport/HTTP error is rethrown untouched.
|
|
*/
|
|
async function readFromCms<T>(
|
|
label: string,
|
|
read: () => Promise<T>,
|
|
fallback: T,
|
|
): Promise<T> {
|
|
try {
|
|
return await read();
|
|
} catch (error) {
|
|
if (isStrapiError(error)) {
|
|
console.error(`[activities] ${label} unavailable: ${error.message}`);
|
|
return fallback;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function toSummary(entity: ActivityEntity): ActivitySummary {
|
|
return {
|
|
id: entity.documentId,
|
|
slug: entity.slug,
|
|
title: entity.title,
|
|
stream: toActivityStreamKey(entity.activityStream),
|
|
summary: entity.summary ?? null,
|
|
// Resolved here rather than in the component: mediaUrl() reads STRAPI_URL
|
|
// from the server environment, which never reaches the browser bundle.
|
|
imageUrl: mediaUrl(entity.coverImage?.url) ?? null,
|
|
imageAlt: mediaAlt(entity.coverImage),
|
|
location: namesOf(entity.regions),
|
|
implementingPartner: namesOf(entity.implementingPartners),
|
|
status: isActivityStatus(entity.activityStatus)
|
|
? entity.activityStatus
|
|
: null,
|
|
};
|
|
}
|
|
|
|
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.
|
|
const locale: Locale = isLocale(params.lang) ? params.lang : DEFAULT_LOCALE;
|
|
|
|
const activities = await readFromCms(
|
|
"activities",
|
|
async () => {
|
|
const response = await getMany<ActivityEntity>("activities", {
|
|
locale,
|
|
sort: [...ACTIVITY_SORT],
|
|
fields: [...ACTIVITY_FIELDS],
|
|
populate: ACTIVITY_POPULATE,
|
|
pagination: { pageSize: ACTIVITY_PAGE_SIZE, withCount: false },
|
|
});
|
|
return response.data;
|
|
},
|
|
[] as ActivityEntity[],
|
|
);
|
|
|
|
return {
|
|
locale,
|
|
activities: activities
|
|
.filter((entity) => Boolean(entity.slug && entity.title))
|
|
.map(toSummary),
|
|
};
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
META
|
|
The canonical link and the en / ar / x-default hreflang alternates are
|
|
emitted for every route by the shell (root.tsx `Layout`), because a leaf
|
|
`meta` replaces its parents' wholesale - so `alternates` stays false here on
|
|
purpose. Passing it would duplicate every one of those tags.
|
|
------------------------------------------------------------------------ */
|
|
|
|
export function meta({ matches }: Route.MetaArgs) {
|
|
const root = readRootMeta(matches, activitiesIndexPath(DEFAULT_LOCALE));
|
|
const { locale, pathname, siteUrl } = root;
|
|
const copy = getIndexCopy(locale);
|
|
const { sections } = getMessages(locale).nav;
|
|
|
|
return buildMeta({
|
|
locale,
|
|
pathname,
|
|
siteUrl,
|
|
title: sections.activities,
|
|
description: copy.metaDescription,
|
|
jsonLd: buildBreadcrumbJsonLd(siteUrl, [
|
|
{ name: sections.home, pathname: sectionPath("home", locale) },
|
|
{ name: sections.activities, pathname: activitiesIndexPath(locale) },
|
|
]),
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
VIEW
|
|
------------------------------------------------------------------------ */
|
|
|
|
export default function ActivitiesAndInitiatives({
|
|
loaderData,
|
|
}: 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 (
|
|
<>
|
|
<ListingHero
|
|
eyebrow={copy.eyebrow}
|
|
heading={copy.headline}
|
|
intro={copy.lede}
|
|
ambient={[{ top: -250, from: "start", offset: "12%", width: 740, height: 580, durationSeconds: 21 }]}
|
|
/>
|
|
|
|
<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-dashed border-border-default 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>
|
|
</section>
|
|
)}
|
|
|
|
<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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
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-chip 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>
|
|
);
|
|
}
|