Files
sharper.ly/apps/web/app/routes/activities-and-initiatives.tsx
T
IllusionandClaude Opus 5 19fab4a783 Match the shipped styling to the Claude Design drafts
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>
2026-09-20 11:14:15 +02:00

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>
);
}