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
+22 -1
View File
@@ -30,7 +30,13 @@
html,
body,
[data-surface] {
background-color: var(--surface-page);
/* The design drafts stand the whole site on #0B0B0A and lift their
bands off it with #1D1D1B, so the document's ground is the SUNKEN
surface and `--surface-page` is the colour of a band. A section that
wants the lighter band paints `bg-[var(--surface-page)]`; one that
wants the ground paints nothing, or `bg-[var(--surface-sunken)]` when
it needs to say so explicitly. */
background-color: var(--surface-sunken);
color: var(--text-primary);
}
@@ -86,3 +92,18 @@
}
}
}
/* ---------------------------------------------------------------------------
BANDS
The design drafts alternate the #0B0B0A ground with lighter #1D1D1B bands,
and a card inside a band is drawn DARKER than the band (#141413) while a
card on the ground is drawn LIGHTER (#1D1D1B). A section paints itself with
`.sh-band` and every Card, InfoPanel and tile inside it follows, instead of
each route guessing its own fill.
------------------------------------------------------------------------ */
@layer components {
.sh-band {
background-color: var(--surface-page);
--surface-card: var(--surface-raised);
}
}
+20 -1
View File
@@ -57,7 +57,26 @@
transition:
background-color var(--motion-duration-fast) var(--motion-ease-standard),
border-color var(--motion-duration-fast) var(--motion-ease-standard),
color var(--motion-duration-fast) var(--motion-ease-standard);
color var(--motion-duration-fast) var(--motion-ease-standard),
transform var(--motion-duration-base) var(--motion-ease-entrance);
}
/* The Claude Design drafts lift a button 3px under the pointer and set it
back down on press. Pointer devices only - a touch "hover" would leave the
control raised after the tap - and never while disabled or busy, where the
control must not look live. app.css collapses the transition under
prefers-reduced-motion, so the lift becomes an instant, harmless offset.
The drafts also put a coloured glow behind the lifted button; that is
deliberately not shipped (client decision). */
@media (hover: hover) {
.sh-btn:hover:not(:disabled):not([aria-disabled="true"]):not([data-loading]) {
transform: translateY(-3px);
}
}
.sh-btn:active:not(:disabled):not([aria-disabled="true"]):not([data-loading]) {
transform: none;
}
.sh-btn--block {
@@ -159,7 +159,7 @@ export function CardShell({
className={cx(
"sh-card",
linked && "sh-card--linked",
"relative flex bg-surface-raised text-text-primary",
"relative flex bg-[var(--surface-card)] text-text-primary",
"rounded-surface border border-border-subtle shadow-[var(--elevation-card)]",
className,
)}
@@ -0,0 +1,112 @@
import { getDirection, type Locale } from "../LanguageSwitcher";
/* Exported from Figma assets/eu-en (224:11275) and assets/eu-en - darkBg
(238:2325). The source files are downscaled copies of the Figma exports. */
const EMBLEM_SRC = {
light: "/assets/eu-flag.png",
dark: "/assets/eu-flag-dark.png",
} as const;
const EMBLEM_INTRINSIC_WIDTH = 192;
const EMBLEM_INTRINSIC_HEIGHT = 128;
/**
* Drawn emblem box per size. "md" is the 48x32 the navigation and the drawer
* use; "sm" is the 36x24 the home hero draws above the wordmark (478:15951).
* Only the emblem changes - see the note on the statement below.
*/
const EMBLEM_BOX = {
md: "h-[32px] w-[48px]",
sm: "h-[24px] w-[36px]",
} as const;
export type EuBadgeSize = keyof typeof EMBLEM_BOX;
/** Which tier-2 surface the badge is sitting on. `:root` is the dark one. */
export type EuBadgeSurface = keyof typeof EMBLEM_SRC;
export interface EuBadgeProps {
/** "Funded by the European Union", already in the reader's language. */
statement: string;
/** Alt text for the emblem. Empty when the statement is rendered beside it. */
emblemAlt?: string;
/** Language of `statement` - drives the font switch and the text direction. */
locale: Locale;
surface: EuBadgeSurface;
/** "md" (48x32) by default; "sm" (36x24) for the hero lockup. */
size?: EuBadgeSize;
className?: string;
}
/**
* The mandatory EU visibility lockup: emblem + funding statement.
*
* The lockup itself is pinned to `dir="ltr"` so the emblem always leads, which
* is how the Arabic navigation frame (334:526) draws it; only the statement
* carries the reader's own direction. A mirroring ancestor still moves the
* whole badge from the inline start to the inline end in Arabic.
*
* DEVIATION FROM FIGMA (colour): the design sets the statement in EU reflex
* blue (hex 0f2a8e). A raw hex is not permitted in a component here, and there
* is no semantic token for a third-party brand colour, so the statement uses
* --text-primary. It stays legible on both surfaces and the emblem still
* carries the EU colours. Add a tier-2 token if the blue has to come back.
*
* DEVIATION FROM FIGMA (type): the hero badge sets the statement at 10px. The
* type scale has no 10px role and a raw font-size is not permitted, so both
* sizes render the statement at `label-md` (12px). The lockup is then a little
* taller than the 24px emblem at "sm"; it is also more legible, which EU
* visibility guidance asks for. Add a tier-2 role if 10px is required.
*/
export function EuBadge({
statement,
emblemAlt = "",
locale,
surface,
size = "md",
className,
}: EuBadgeProps) {
return (
<div
dir="ltr"
className={["flex shrink-0 items-center gap-2xs", className]
.filter(Boolean)
.join(" ")}
>
<img
src={EMBLEM_SRC[surface]}
alt={emblemAlt}
width={EMBLEM_INTRINSIC_WIDTH}
height={EMBLEM_INTRINSIC_HEIGHT}
loading="eager"
decoding="async"
draggable={false}
className={`block shrink-0 ${EMBLEM_BOX[size]}`}
/>
<span
lang={locale}
dir={getDirection(locale)}
className="label-md block w-[96px] text-[var(--text-primary)]"
>
{statement}
</span>
</div>
);
}
export default EuBadge;
/* ---------------------------------------------------------------------------
USAGE
---------------------------------------------------------------------------
// In the mobile drawer, on the dark surface:
<EuBadge statement={t("site.euStatement")} locale="en" surface="dark" />
// Above the home hero wordmark, at the smaller emblem size:
<EuBadge
size="sm"
statement={t("site.euStatement")}
locale={locale}
surface="dark"
/>
------------------------------------------------------------------------ */
@@ -0,0 +1,2 @@
export { EuBadge, default } from "./EuBadge";
export type { EuBadgeProps, EuBadgeSize, EuBadgeSurface } from "./EuBadge";
@@ -0,0 +1,40 @@
/* ============================================================================
FOOTER - the sitemap link's hover
----------------------------------------------------------------------------
The design draft moves a hovered footer link along ONE axis and eases it:
transition: color .2s ease, transform .25s ease;
:hover { color: #4DAA93; transform: translateX(4px); }
Two reasons this is CSS rather than Tailwind utilities:
1. `translate-x-*` compiles to the two-value form
`translate: var(--tw-translate-x) var(--tw-translate-y)`, so whatever Y
translate happens to be in scope rides along and the link drifts
diagonally. `translate: 4px 0` pins the second axis at zero.
2. The movement has to be in the transition list. A `transition-[color]`
utility animates the colour and snaps the offset.
The direction is physical because `translate` has no logical form, so the
RTL rule mirrors it - the link always moves toward the reading edge.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
.sh-footer-link {
transition:
color var(--motion-duration-fast) var(--motion-ease-standard),
translate var(--motion-duration-base) var(--motion-ease-standard);
}
@media (hover: hover) {
.sh-footer-link:hover {
translate: 4px 0;
}
[dir="rtl"] .sh-footer-link:hover {
translate: -4px 0;
}
}
}
+83 -102
View File
@@ -36,12 +36,14 @@ import type {
import {
DEFAULT_FOOTER_COPY,
DEFAULT_FOOTER_PARTNERS,
DEFAULT_FOOTER_SOCIAL_LINKS,
defaultFooterSocialLinks,
FOOTER_ASSETS,
FOOTER_EU_EMBLEM_SIZE,
FOOTER_WORDMARK_SIZE,
defaultFooterLinkGroups,
} from "./content";
import "./Footer.css";
import { EuBadge } from "../EuBadge";
import { FooterSocialIcon } from "./FooterSocialIcon";
const cx = (...parts: readonly (string | false | undefined)[]): string =>
@@ -69,18 +71,20 @@ const TRANSITION =
/** Body/Large Bold links - 18/28, as the footer frame sets them. */
const LINK_CLASS = cx(
FOCUS_RING,
TRANSITION,
"body-lg-bold inline-block text-[var(--text-primary)]",
"hover:text-[var(--text-link)] hover:underline underline-offset-4",
/* Colour AND the 4px nudge, both eased, both in ./Footer.css - see the note
there on why this is not a `translate-x` utility. */
"sh-footer-link",
"body-md-bold inline-block no-underline text-[var(--text-primary)]",
"hover:text-[var(--text-accent)]",
);
/** Label/Medium - the "implemented by" label (figma 228:3035). */
const LABEL_CLASS = "label-md uppercase";
/** Body/Small Bold - the sitemap column headings (figma 140:7823 / 140:7834).
/** The drafts set the column headings as a small uppercase label.
`--text-tertiary` is already remapped to a readable step on the light
surface, so the same token is correct on both. */
const GROUP_HEADING_CLASS = "body-sm-bold uppercase text-[var(--text-tertiary)]";
const GROUP_HEADING_CLASS = "label-md uppercase text-[var(--text-tertiary)]";
/**
* Internal links go through the router so the footer navigates client-side;
@@ -106,7 +110,7 @@ export function Footer({
locale = "en",
dir,
linkGroups,
socialLinks = DEFAULT_FOOTER_SOCIAL_LINKS,
socialLinks,
partners = DEFAULT_FOOTER_PARTNERS,
copy: copyOverrides,
homeHref,
@@ -118,6 +122,9 @@ export function Footer({
// would blank the default, so only defined overrides are applied.
const copy: FooterCopy = { ...DEFAULT_FOOTER_COPY[locale], ...definedOnly(copyOverrides) };
const groups = linkGroups ?? defaultFooterLinkGroups(locale);
/* `[]` from a caller hides the row deliberately; `undefined` means "nobody
said", so the project's own confirmed channels stand in. */
const social = socialLinks ?? defaultFooterSocialLinks(locale);
const home = homeHref ?? `/${locale}`;
return (
@@ -126,26 +133,17 @@ export function Footer({
dir={dir}
data-surface={surface}
className={cx(
// data-surface sets the palette, but the frame puts the footer on its
// own ground (base/black on dark) with a rule separating it from the
// page, so those two are stated here rather than inherited.
"w-full border-t border-[var(--border-default)] bg-[var(--surface-footer)]",
/* The drafts stand the footer on its own ground (#141413) under a
hairline, rather than letting it inherit the page surface. */
"w-full border-t border-[var(--border-subtle)] bg-[var(--surface-footer)]",
"px-lg pb-2xl pt-[var(--layout-section-gap)] md:px-xl",
className,
)}
>
<div className="mx-auto flex w-full max-w-[var(--layout-content-max)] flex-col gap-2xl">
{/* ---- brand + sitemap (figma 140:7684 / 140:8686) ---------------- */}
<div className="flex flex-col gap-2xl lg:flex-row lg:gap-3xl">
{/* --- brand column (figma 140:7685 / 140:8687) ----------------- */}
<div
className={cx(
"flex flex-col gap-md",
// the divider sits on the inline-end of the brand block, which is
// the left edge once <html dir="rtl"> mirrors the row.
"lg:w-[345px] lg:shrink-0 lg:border-e lg:border-[var(--border-subtle)] lg:pe-2xl",
)}
>
{/* ---- brand column + sitemap --------------------------------- */}
<div className="grid gap-xl lg:grid-cols-[minmax(11.875rem,1fr)_2fr]">
<div className="flex flex-col items-start gap-md">
<Link to={home} aria-label={copy.homeLabel} className={cx(FOCUS_RING, "w-fit")}>
<img
src={isDark ? FOOTER_ASSETS.wordmarkInverse : FOOTER_ASSETS.wordmark}
@@ -158,46 +156,43 @@ export function Footer({
/>
</Link>
<p className="body-sm text-[var(--text-secondary)]">{copy.tagline}</p>
<p className="body-sm max-w-[46ch] text-[var(--text-primary)]">
{copy.description}
{/* One line: the full name and the dates the project runs. */}
<p className="body-sm max-w-[13.75rem] text-[var(--text-secondary)]">
{copy.brandLine}
</p>
{/* --- EU visibility (figma 140:7688 / 238:2325) -------------- */}
<div className="flex items-center gap-2xs pt-2xs">
<img
src={isDark ? FOOTER_ASSETS.euEmblemInverse : FOOTER_ASSETS.euEmblem}
alt={copy.euEmblemAlt}
width={FOOTER_EU_EMBLEM_SIZE.width}
height={FOOTER_EU_EMBLEM_SIZE.height}
className="block h-[32px] w-[48px] shrink-0"
loading="lazy"
decoding="async"
/>
<p className="body-sm-bold max-w-[24ch] text-[var(--text-primary)]">
{copy.euStatement}
</p>
</div>
{/* The draft draws each channel as a bordered button with its
glyph and a written label, not as a bare icon. */}
{social.length > 0 && (
<>
<h2 className="sr-only">{copy.socialHeading}</h2>
<ul className="flex flex-wrap gap-xs">
{social.map((link) => (
<li key={link.platform}>
<SocialButton link={link} />
</li>
))}
</ul>
</>
)}
{socialLinks.length > 0 && <SocialRow copy={copy} links={socialLinks} />}
{/* EU visibility, through the shared lockup the hero uses. */}
<EuBadge
statement={copy.euStatement}
locale={locale}
surface={surface}
size="sm"
/>
</div>
{/* --- sitemap columns (figma 380:12402) ------------------------ */}
<nav aria-label={copy.navLabel} className="flex flex-1 flex-col gap-2xl sm:flex-row sm:gap-0">
{groups.map((group, index) => (
<div
key={group.id}
className={cx(
"flex min-w-0 flex-col gap-md sm:flex-1",
// the rule sits on the inline-start of every column but the
// first, so it mirrors with the row under dir="rtl".
index > 0 && "sm:border-s sm:border-[var(--border-subtle)] sm:ps-xl",
index < groups.length - 1 && "sm:pe-xl",
)}
>
<nav
aria-label={copy.navLabel}
className="grid gap-xl sm:grid-cols-2 lg:grid-cols-3"
>
{groups.map((group) => (
<div key={group.id} className="flex min-w-0 flex-col gap-sm">
<h2 className={GROUP_HEADING_CLASS}>{group.heading}</h2>
<ul className="flex flex-col gap-lg">
<ul className="flex flex-col gap-sm">
{group.links.map((link: FooterLink, linkIndex: number) => (
<li key={`${link.href}-${linkIndex}`}>
<FooterNavLink link={link} className={LINK_CLASS} />
@@ -209,19 +204,15 @@ export function Footer({
</nav>
</div>
{/* ---- implemented-by bar (figma 228:3062 / 140:8710) ------------- */}
{/* ---- implemented by ------------------------------------------ */}
{partners.length > 0 && (
<div className="flex flex-col gap-lg border-t border-[var(--border-default)] pt-lg sm:flex-row sm:items-center sm:justify-between">
{/* Figma paints this label background/accent on dark and
text/secondary on light; --text-accent is the readable green. */}
<p
className={cx(
LABEL_CLASS,
"text-[var(--text-secondary)] surface-dark:text-[var(--text-accent)]",
)}
>
<div className="flex flex-wrap items-center justify-between gap-lg border-t border-[var(--border-subtle)] pt-lg">
<p className={cx(LABEL_CLASS, "text-[var(--text-secondary)]")}>
{copy.implementedByLabel}
</p>
{/* The draft prints the three names in bordered chips because it
cannot embed artwork. The real marks are shown instead - they
are contractual visibility and must not be substituted. */}
<ul className="flex flex-wrap items-center gap-lg">
{partners.map((partner) => (
<li key={partner.id} className="flex items-center">
@@ -231,6 +222,11 @@ export function Footer({
</ul>
</div>
)}
{/* ---- the funding disclaimer closes the footer ----------------- */}
<p className="body-sm max-w-[48.75rem] text-[var(--text-tertiary)]">
{copy.disclaimer}
</p>
</div>
</footer>
);
@@ -270,44 +266,29 @@ function PartnerMark({ isDark, partner }: { isDark: boolean; partner: FooterPart
}
/* ---------------------------------------------------------------------------
Social row. Not in the published frames - the draft copy asks for it, so it
closes the brand column. The negative inline-start margin cancels the icon
padding so the glyphs optically align with the text above them.
One social channel, drawn as the draft's bordered button: the glyph, then
the written label ("Follow us on Facebook"), so the control names itself
instead of relying on an icon alone.
------------------------------------------------------------------------ */
function SocialRow({
copy,
links,
}: {
copy: FooterCopy;
links: readonly FooterSocialLink[];
}) {
function SocialButton({ link }: { link: FooterSocialLink }) {
return (
<div className="pt-2xs">
<h2 className="sr-only">{copy.socialHeading}</h2>
<ul className="-ms-2xs flex flex-wrap items-center gap-2xs">
{links.map((link) => (
<li key={link.platform}>
<a
href={link.href}
aria-label={link.label}
target="_blank"
rel="noopener noreferrer"
className={cx(
FOCUS_RING,
TRANSITION,
"inline-flex items-center justify-center p-2xs",
"text-[var(--text-secondary)] hover:text-[var(--text-link)]",
)}
>
<FooterSocialIcon
platform={link.platform}
className="h-[var(--control-icon-size)] w-[var(--control-icon-size)]"
/>
</a>
</li>
))}
</ul>
</div>
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
className={cx(
FOCUS_RING,
TRANSITION,
"inline-flex items-center gap-xs rounded-control no-underline",
"border border-[var(--border-subtle)] py-xs ps-sm pe-md",
"text-[var(--text-primary)]",
"hover:border-[var(--border-accent)] hover:bg-[var(--surface-page)]",
"hover:text-[var(--text-accent)]",
)}
>
<FooterSocialIcon platform={link.platform} className="h-[17px] w-[17px]" />
<span className="body-sm-bold">{link.label}</span>
</a>
);
}
+37 -7
View File
@@ -8,6 +8,7 @@
========================================================================= */
import { ROUTE_SLUGS } from "~/lib/route-slugs";
import { SITE_FACEBOOK_URL } from "~/lib/site-links";
import type {
FooterCopy,
@@ -53,23 +54,25 @@ const path = (locale: FooterLocale, slug: string): string => `/${locale}/${slug}
------------------------------------------------------------------------ */
export const DEFAULT_FOOTER_COPY: Readonly<Record<FooterLocale, FooterCopy>> = {
en: {
tagline: "Shared Action on Rule of Law Progress – Exit Route",
description:
"A nationwide initiative supporting Libya’s civil society, local institutions and communities in advancing justice, accountability and good governance. Running from December 2025 to December 2027.",
brandLine:
"Shared Action on Rule of Law Progress — Exit Route. December 2025 – December 2027.",
euStatement: "This project is funded by the European Union",
euEmblemAlt: "Emblem of the European Union",
implementedByLabel: "Implemented by",
disclaimer:
"This website was produced with the financial support of the European Union. Its contents are the sole responsibility of the SHARP-ER consortium and do not necessarily reflect the views of the European Union.",
socialHeading: "Follow SHARP-ER",
homeLabel: "SHARP-ER — home",
navLabel: "Site sections",
},
ar: {
tagline: "العمل المشترك لتعزيز سيادة القانون – مسار الخروج",
description:
"مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات الليبية في تعزيز العدالة والمساءلة والحوكمة الرشيدة. يمتد المشروع من ديسمبر 2025 إلى ديسمبر 2027.",
brandLine:
"العمل المشترك لتعزيز سيادة القانون – مسار الخروج. ديسمبر 2025 – ديسمبر 2027.",
euStatement: "هذا المشروع ممول من الاتحاد الأوروبي",
euEmblemAlt: "شعار الاتحاد الأوروبي",
implementedByLabel: "الجهات المنفّذة",
disclaimer:
"أُنتج هذا الموقع الإلكتروني بدعم مالي من الاتحاد الأوروبي. ومحتوياته هي مسؤولية ائتلاف SHARP-ER وحده، ولا تعكس بالضرورة وجهات نظر الاتحاد الأوروبي.",
socialHeading: "تابعوا SHARP-ER",
homeLabel: "SHARP-ER — الصفحة الرئيسية",
navLabel: "أقسام الموقع",
@@ -199,4 +202,31 @@ export const DEFAULT_FOOTER_PARTNERS: readonly FooterPartner[] = [
Real profiles arrive through the `socialLinks` prop (Strapi); the footer's
`links.length > 0` guard drops the whole row until they do.
------------------------------------------------------------------------ */
export const DEFAULT_FOOTER_SOCIAL_LINKS: readonly FooterSocialLink[] = [];
export const DEFAULT_FOOTER_SOCIAL_LINKS_BY_LOCALE: Readonly<
Record<FooterLocale, readonly FooterSocialLink[]>
> = {
en: [
{
platform: "facebook",
label: "Follow us on Facebook",
href: SITE_FACEBOOK_URL,
},
],
ar: [
{
platform: "facebook",
label: "تابعونا على فيسبوك",
href: SITE_FACEBOOK_URL,
},
],
};
/** The English row, kept as the old name so the barrel's surface is unchanged. */
export const DEFAULT_FOOTER_SOCIAL_LINKS: readonly FooterSocialLink[] =
DEFAULT_FOOTER_SOCIAL_LINKS_BY_LOCALE.en;
export function defaultFooterSocialLinks(
locale: FooterLocale,
): readonly FooterSocialLink[] {
return DEFAULT_FOOTER_SOCIAL_LINKS_BY_LOCALE[locale];
}
+10 -4
View File
@@ -67,16 +67,22 @@ export interface FooterPartner {
/** Every string the footer renders that is not a link label. */
export interface FooterCopy {
/** "Shared Action on Rule of Law Progress - Exit Route" */
readonly tagline: string;
/** Short programme description under the wordmark. */
readonly description: string;
/**
* The single line under the wordmark: the full project name and the dates
* it runs, exactly as the design draft sets it.
*/
readonly brandLine: string;
/** EU funding statement rendered next to the emblem. */
readonly euStatement: string;
/** Alt text for the EU emblem image. */
readonly euEmblemAlt: string;
/** Label above the consortium logos. */
readonly implementedByLabel: string;
/**
* The EU funding disclaimer that closes the footer. Contractual visibility
* wording, carried verbatim from the approved design draft.
*/
readonly disclaimer: string;
/** Visually hidden heading for the social row. */
readonly socialHeading: string;
/** Accessible name of the wordmark link. */
@@ -0,0 +1,273 @@
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type FocusEvent as ReactFocusEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { Link } from "react-router";
import "./LanguageSwitcher.css";
import {
buildLocalizedHref,
getDirection,
LOCALE_ENDONYM,
LOCALES,
type Locale,
} from "./locale-path";
/* Reinforces the global :focus-visible rule in app.css with the same tier-2
focus tokens, so the ring is visible even if a caller overrides `outline`. */
const FOCUS_RING =
"focus-visible:outline-[color:var(--focus-ring-color)] " +
"focus-visible:outline-[length:var(--focus-ring-width)] " +
"focus-visible:outline-offset-[var(--focus-ring-offset)]";
const TRANSITION =
"transition-colors duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]";
type OptionPosition = "first" | "last";
export interface LanguageMenuProps {
/** The locale the reader is currently on. */
locale: Locale;
/** Current pathname, e.g. "/en/news-and-updates". */
pathname: string;
/** Query string to carry across the switch. Usually `location.search`. */
search?: string;
/** Fragment to carry across the switch. Usually `location.hash`. */
hash?: string;
/**
* Accessible name of the globe, in the reader's language ("Language"). It is
* required because the globe carries no text of its own.
*/
label: string;
/** Navigate with a full document request instead of a client transition. */
reloadDocument?: boolean;
className?: string;
}
/**
* The navigation's language control: the globe (figma 478:16381) as a button
* that opens a short list of the site's languages, each linking to the page the
* reader is on under that locale, with the query string and fragment kept.
*
* Not drawn in Figma - the design shows the globe alone - so the list is built
* from the tier-2 surface, border and elevation tokens a popover uses, and
* hangs from the globe's inline-end edge so it opens inward in both directions.
*
* A disclosure rather than an ARIA menu: the entries are ordinary links and are
* announced and followed as links. Each carries its own lang/hrefLang and its
* label its own dir, so a screen reader switches voice for "العربية". The
* current language is marked with aria-current and set in bold, so the state is
* not carried by colour alone. ArrowDown/ArrowUp open the list and move through it, Escape closes it
* and returns focus to the globe, and an outside press or focus leaving closes
* it. Focus is never trapped.
*/
export function LanguageMenu({
locale,
pathname,
search,
hash,
label,
reloadDocument = false,
className,
}: LanguageMenuProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const pendingFocus = useRef<OptionPosition | null>(null);
const rawId = useId();
const listId = `sharper-language-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const options = useCallback(
(): HTMLElement[] =>
Array.from(
rootRef.current?.querySelectorAll<HTMLElement>("[data-language-option]") ??
[],
),
[],
);
const focusOption = useCallback(
(position: OptionPosition) => {
const all = options();
(position === "first" ? all[0] : all[all.length - 1])?.focus();
},
[options],
);
const close = useCallback(() => {
pendingFocus.current = null;
setOpen(false);
}, []);
/* Outside press. pointerdown rather than click so the list is already gone
by the time the click lands on whatever is underneath. */
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
/* A keyboard request to open and step into the list can only be honoured
once the list has rendered. */
useEffect(() => {
const position = pendingFocus.current;
if (!open || position === null) return;
pendingFocus.current = null;
focusOption(position);
}, [open, focusOption]);
const openAndFocus = (position: OptionPosition) => {
if (open) {
focusOption(position);
return;
}
pendingFocus.current = position;
setOpen(true);
};
const handleButtonKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowDown") {
event.preventDefault();
openAndFocus("first");
} else if (event.key === "ArrowUp") {
event.preventDefault();
openAndFocus("last");
}
};
const handleOptionKeyDown = (event: ReactKeyboardEvent<HTMLAnchorElement>) => {
const all = options();
const index = all.indexOf(event.currentTarget);
const moveTo = (to: number) => all[(to + all.length) % all.length]?.focus();
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveTo(index + 1);
break;
case "ArrowUp":
event.preventDefault();
moveTo(index - 1);
break;
case "Home":
event.preventDefault();
moveTo(0);
break;
case "End":
event.preventDefault();
moveTo(all.length - 1);
break;
default:
break;
}
};
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Escape" || !open) return;
event.preventDefault();
close();
buttonRef.current?.focus();
};
/* React's onBlur is focusout, so it fires for descendants too. */
const handleBlur = (event: ReactFocusEvent<HTMLDivElement>) => {
const next = event.relatedTarget as Node | null;
if (!next || !rootRef.current?.contains(next)) close();
};
return (
<div
ref={rootRef}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className={["relative", className].filter(Boolean).join(" ")}
>
<button
ref={buttonRef}
type="button"
aria-expanded={open}
aria-controls={listId}
aria-label={label}
title={label}
onClick={() => (open ? close() : setOpen(true))}
onKeyDown={handleButtonKeyDown}
className={[
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control",
"text-[var(--text-primary)] hover:text-[var(--text-accent)]",
"aria-expanded:text-[var(--text-accent)]",
TRANSITION,
FOCUS_RING,
].join(" ")}
>
<span aria-hidden="true" className="sh-language-globe" />
</button>
<ul
id={listId}
hidden={!open}
className={[
"absolute end-0 top-full z-[var(--layer-dropdown)] mt-sm",
"flex min-w-[10rem] flex-col gap-2xs p-2xs",
"rounded-control border border-[var(--border-subtle)]",
"bg-[var(--surface-raised)] shadow-[var(--elevation-popover)]",
].join(" ")}
>
{LOCALES.map((option) => {
const current = option === locale;
return (
<li key={option}>
<Link
to={buildLocalizedHref(pathname, option, { search, hash })}
reloadDocument={reloadDocument}
hrefLang={option}
lang={option}
aria-current={current ? "true" : undefined}
data-language-option={option}
onClick={close}
onKeyDown={handleOptionKeyDown}
className={[
current ? "body-md-bold" : "body-md",
"block rounded-control px-sm py-xs text-start no-underline",
"text-[var(--text-primary)] hover:bg-[var(--surface-muted)]",
"aria-[current=true]:text-[var(--text-accent)]",
TRANSITION,
FOCUS_RING,
].join(" ")}
>
{/* The label carries its own direction; the row does not, so
both names line up on the page's inline-start edge instead
of each hugging the side its own script reads from. */}
<span dir={getDirection(option)}>{LOCALE_ENDONYM[option]}</span>
</Link>
</li>
);
})}
</ul>
</div>
);
}
export default LanguageMenu;
/* ---------------------------------------------------------------------------
USAGE
---------------------------------------------------------------------------
// In the navigation bar, wired from the router's location:
const { pathname, search, hash } = useLocation();
<LanguageMenu
label={strings.languageMenuLabel}
locale={locale}
pathname={pathname}
search={search}
hash={hash}
/>
------------------------------------------------------------------------ */
@@ -0,0 +1,35 @@
/* ============================================================================
LANGUAGE SWITCHER - figma `globe` 478:16381 (navigation 20:236)
----------------------------------------------------------------------------
The navigation now carries the language control as a bare 24x24 globe in the
Actions slot, replacing the "AR" / "EN" pill that used to sit next to the EU
badge. The exported asset fills its path with a hard-coded #1D1D1B, which
would be invisible on the dark surface, so it is applied as a mask over
`currentColor` - the same technique as `.sh-arrow-icon` and `.sh-card-icon`.
That keeps the exact exported geometry while the colour comes from whichever
semantic token the bar has in force.
GEOMETRY, straight from Figma: a 24x24 box (`--control-icon-size`) with the
glyph filling it edge to edge. The globe is radially symmetric, so unlike the
arrow it needs no RTL mirroring.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
.sh-language-globe {
display: block;
flex: none;
inline-size: var(--control-icon-size);
block-size: var(--control-icon-size);
background-color: currentColor;
-webkit-mask-image: url("/assets/icons/globe-solid.svg");
mask-image: url("/assets/icons/globe-solid.svg");
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
}
}
@@ -42,7 +42,7 @@ export interface LanguageSwitcherProps {
* document. Turn it on if a page needs the server to re-render first.
*/
reloadDocument?: boolean;
/** "sm" for the desktop bar, "md" for a 44px touch target in the drawer. */
/** "sm" for a compact pill, "md" for a 44px touch target in the drawer. */
size?: LanguageSwitcherSize;
className?: string;
}
@@ -52,9 +52,8 @@ export interface LanguageSwitcherProps {
* ("AR" while reading English, "EN" while reading Arabic) and links to the
* same page under that locale, preserving the rest of the path.
*
* The Figma navigation has no language switcher - a gap recorded in
* docs/SHARP-ER_Website Recommendations.docx - so this is designed to sit in
* the existing bar: label-md on a pill outline, matching the EU badge height.
* The mobile drawer uses this, where a spelled-out target is clearer than an
* icon. The desktop bar uses <LanguageMenu />, the globe with a dropdown.
*/
export function LanguageSwitcher({
locale,
@@ -114,8 +113,4 @@ export default LanguageSwitcher;
// Force a document navigation (server re-renders <html lang>/<html dir>):
<LanguageSwitcher locale={locale} pathname={pathname} reloadDocument />
// Wired from a route, using the router's own location:
const { pathname, search, hash } = useLocation();
<LanguageSwitcher locale={locale} pathname={pathname} search={search} hash={hash} />
------------------------------------------------------------------------ */
@@ -0,0 +1,119 @@
import { Link } from "react-router";
import "./LanguageSwitcher.css";
import {
getCounterpartLocale,
getCounterpartPath,
getDirection,
LOCALE_ENDONYM,
LOCALE_SHORT_LABEL,
type Locale,
} from "./locale-path";
/* Reinforces the global :focus-visible rule in app.css with the same tier-2
focus tokens, so the ring is visible even if a caller overrides `outline`. */
const FOCUS_RING =
"focus-visible:outline-[color:var(--focus-ring-color)] " +
"focus-visible:outline-[length:var(--focus-ring-width)] " +
"focus-visible:outline-offset-[var(--focus-ring-offset)]";
export interface LanguageToggleProps {
/** The locale the reader is currently on. */
locale: Locale;
/** Current pathname, e.g. "/en/news-and-updates". */
pathname: string;
/** Query string to carry across the switch. Usually `location.search`. */
search?: string;
/** Fragment to carry across the switch. Usually `location.hash`. */
hash?: string;
/** Accessible name of the pair ("Language"), in the reader's language. */
label: string;
/** Navigate with a full document request instead of a client transition. */
reloadDocument?: boolean;
className?: string;
}
/**
* Both languages, side by side: the globe, the current locale in an accent
* chip, and the other locale as a link to the same page under that locale -
* the redesign's nav control, replacing the globe-plus-dropdown on the desktop
* bar.
*
* One tap to switch and no state to open, which is why the draft prefers it;
* the drawer still uses <LanguageSwitcher /> and <LanguageMenu /> remains for
* anywhere a list of more than two languages is needed.
*
* The current language is marked with `aria-current`, not by colour alone. The
* link carries its own lang/hrefLang/dir and is named with the target
* language's endonym ("العربية"), so a screen reader switches voice for it
* while the visible "AR" stays a two-letter code.
*/
export function LanguageToggle({
locale,
pathname,
search,
hash,
label,
reloadDocument = false,
className,
}: LanguageToggleProps) {
const target = getCounterpartLocale(locale);
const href = getCounterpartPath(pathname, locale, { search, hash });
return (
<div
role="group"
aria-label={label}
className={[
"flex shrink-0 items-center gap-xs text-[var(--text-secondary)]",
className,
]
.filter(Boolean)
.join(" ")}
>
<span aria-hidden="true" className="sh-language-globe" />
<span className="flex items-center">
<span
aria-current="true"
lang={locale}
dir={getDirection(locale)}
className="label-md rounded-control bg-[var(--surface-accent)] px-xs py-2xs text-[var(--text-on-accent)]"
>
{LOCALE_SHORT_LABEL[locale]}
</span>
<Link
to={href}
reloadDocument={reloadDocument}
hrefLang={target}
lang={target}
dir={getDirection(target)}
aria-label={LOCALE_ENDONYM[target]}
title={LOCALE_ENDONYM[target]}
data-language-switcher={target}
className={[
"label-md rounded-control px-xs py-2xs no-underline",
"text-[var(--text-secondary)]",
"transition-colors duration-[var(--motion-duration-fast)]",
"ease-[var(--motion-ease-standard)]",
"hover:text-[var(--text-primary)]",
FOCUS_RING,
].join(" ")}
>
<span aria-hidden="true">{LOCALE_SHORT_LABEL[target]}</span>
</Link>
</span>
</div>
);
}
export default LanguageToggle;
/* ---------------------------------------------------------------------------
USAGE
---------------------------------------------------------------------------
// Reading English at /en/news-and-updates -> "EN" chipped, "AR" links to
// /ar/news-and-updates.
<LanguageToggle locale="en" pathname="/en/news-and-updates" label="Language" />
------------------------------------------------------------------------ */
@@ -3,6 +3,10 @@ export type {
LanguageSwitcherProps,
LanguageSwitcherSize,
} from "./LanguageSwitcher";
export { LanguageMenu } from "./LanguageMenu";
export type { LanguageMenuProps } from "./LanguageMenu";
export { LanguageToggle } from "./LanguageToggle";
export type { LanguageToggleProps } from "./LanguageToggle";
export {
buildLocalizedHref,
@@ -18,9 +18,50 @@ import { isNavGroup, type NavItem, type NavigationStrings } from "./types";
/* The trigger row and the column row MUST use the same gap: the columns are
width-matched to their triggers, so an identical gap is what makes every
column line up under its own heading in both directions. */
/* The draft marks a group trigger with a chevron and each menu entry with a
forward arrow. Both are decorative: the label beside them carries the name. */
function Chevron() {
return (
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="shrink-0"
>
<path d="M5 9l7 7 7-7" />
</svg>
);
}
function MenuArrow() {
return (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
/* Mirrors with the page: the arrow points at the reading edge. */
className="shrink-0 rtl:-scale-x-100"
>
<path d="M5 12h13M12.5 6.5L18 12l-5.5 5.5" />
</svg>
);
}
const ROW_GAP = "gap-2xl";
/** Grace period before a pointer leaving the bar closes the mega menu. */
/** Grace period before a pointer leaving the bar closes the open group. */
const CLOSE_DELAY_MS = 160;
export interface DesktopNavProps {
@@ -36,12 +77,22 @@ export interface DesktopNavProps {
type ColumnPosition = "first" | "last";
/**
* Desktop navigation with the Figma mega menu (317:6063): opening reveals every
* group's children at once, each column sitting directly under its own trigger.
* Desktop navigation. Every group is a disclosure: hovering its trigger, or
* opening it from the keyboard, reveals that group's links - and only that
* group's - in a column directly under its own trigger.
*
* Opens on hover and on keyboard focus, closes on Escape, on outside click and
* when focus leaves the bar. Tab is deliberately untouched, so focus is never
* trapped; the arrow keys add direct movement on top of it.
* DEVIATION FROM FIGMA: the mega menu (317:6063) reveals every group's column
* at once. The designer has since asked for only the hovered group to open, so
* the panel keeps the Figma column geometry - each column under its trigger, on
* the bar's own surface - but shows one column at a time, on a backdrop as wide
* as the row of items rather than the whole window.
*
* Keyboard follows the disclosure-navigation pattern: a trigger does not open
* on focus; Enter, Space and ArrowDown open it. The columns share one backdrop,
* so they come after the whole trigger row in the DOM; Tab is bridged to match
* what the reader sees - from an open trigger it steps into that column, and
* from the column's last link it moves on to the next trigger. Escape, an
* outside press and focus leaving the bar all close it. Focus is never trapped.
*/
export function DesktopNav({
items,
@@ -50,12 +101,16 @@ export function DesktopNav({
direction,
className,
}: DesktopNavProps) {
const [open, setOpen] = useState(false);
/** The one group whose column is showing, or null when nothing is open. */
const [openGroup, setOpenGroup] = useState<string | null>(null);
const navRef = useRef<HTMLElement>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingFocus = useRef<{ groupId: string; position: ColumnPosition } | null>(
null,
);
/* How the last press on a trigger began, so a mouse click does not close the
column that hovering the same trigger has just opened. */
const lastPointerType = useRef<string | null>(null);
const rawId = useId();
const baseId = `sharper-nav-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
@@ -71,44 +126,27 @@ export function DesktopNav({
}
}, []);
const openMega = useCallback(() => {
clearCloseTimer();
setOpen(true);
}, [clearCloseTimer]);
const showGroup = useCallback(
(groupId: string) => {
clearCloseTimer();
setOpenGroup(groupId);
},
[clearCloseTimer],
);
const closeMega = useCallback(() => {
const closeGroup = useCallback(() => {
clearCloseTimer();
pendingFocus.current = null;
setOpen(false);
setOpenGroup(null);
}, [clearCloseTimer]);
const scheduleClose = useCallback(() => {
clearCloseTimer();
closeTimer.current = setTimeout(() => setOpen(false), CLOSE_DELAY_MS);
closeTimer.current = setTimeout(() => setOpenGroup(null), CLOSE_DELAY_MS);
}, [clearCloseTimer]);
useEffect(() => clearCloseTimer, [clearCloseTimer]);
/* Outside click. pointerdown rather than click so the menu is already gone
by the time the click lands on whatever is underneath. */
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (!navRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
/* A keyboard request to open and step into a column can only be honoured
once the column has actually rendered. */
useEffect(() => {
if (!open) return;
const target = pendingFocus.current;
pendingFocus.current = null;
if (target) focusInColumn(target.groupId, target.position);
}, [open]);
const columnLinks = useCallback((groupId: string): HTMLElement[] => {
const nav = navRef.current;
if (!nav) return [];
@@ -143,17 +181,37 @@ export function DesktopNav({
[columnLinks],
);
/** Open (if needed) and put focus on the first/last link of `groupId`. */
/* Outside press. pointerdown rather than click so the column is already gone
by the time the click lands on whatever is underneath. */
useEffect(() => {
if (openGroup === null) return;
const handlePointerDown = (event: PointerEvent) => {
if (!navRef.current?.contains(event.target as Node)) setOpenGroup(null);
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [openGroup]);
/* A keyboard request to open a group and step into its column can only be
honoured once that column has actually rendered. */
useEffect(() => {
const target = pendingFocus.current;
if (openGroup === null || !target || target.groupId !== openGroup) return;
pendingFocus.current = null;
focusInColumn(target.groupId, target.position);
}, [openGroup, focusInColumn]);
/** Open `groupId` (if needed) and put focus on its first/last link. */
const enterColumn = useCallback(
(groupId: string, position: ColumnPosition) => {
if (open) {
if (openGroup === groupId) {
focusInColumn(groupId, position);
return;
}
pendingFocus.current = { groupId, position };
openMega();
showGroup(groupId);
},
[focusInColumn, open, openMega],
[focusInColumn, openGroup, showGroup],
);
const forwardKey = direction === "rtl" ? "ArrowLeft" : "ArrowRight";
@@ -167,6 +225,15 @@ export function DesktopNav({
next?.focus();
};
const handleTriggerClick = (groupId: string) => {
const pointer = lastPointerType.current;
lastPointerType.current = null;
/* A mouse has already opened the column by hovering; a click keeps it
open. Touch and pen have no hover, so for them a press toggles. */
if (pointer === "mouse" || openGroup !== groupId) showGroup(groupId);
else closeGroup();
};
const handleTriggerKeyDown = (
event: ReactKeyboardEvent<HTMLButtonElement>,
groupId: string,
@@ -176,7 +243,7 @@ export function DesktopNav({
case " ":
case "Spacebar":
event.preventDefault();
if (open) closeMega();
if (openGroup === groupId) closeGroup();
else enterColumn(groupId, "first");
break;
case "ArrowDown":
@@ -205,6 +272,16 @@ export function DesktopNav({
controls[controls.length - 1]?.focus();
break;
}
case "Tab": {
/* Into the open column, which the DOM places after the trigger row. */
if (event.shiftKey || openGroup !== groupId) break;
const first = columnLinks(groupId)[0];
if (first) {
event.preventDefault();
first.focus();
}
break;
}
default:
break;
}
@@ -227,7 +304,7 @@ export function DesktopNav({
if (groupIds.length === 0) return;
const nextId =
groupIds[(groupIndex + delta + groupIds.length) % groupIds.length];
if (nextId) focusInColumn(nextId, "first");
if (nextId) enterColumn(nextId, "first");
};
switch (event.key) {
@@ -256,6 +333,27 @@ export function DesktopNav({
event.preventDefault();
links[links.length - 1]?.focus();
break;
case "Tab": {
if (event.shiftKey) {
/* Back out of the column to the trigger that owns it. */
if (index === 0) {
event.preventDefault();
triggerFor(groupId)?.focus();
}
break;
}
/* On from the end of the column to the next top-level item, which
closes this group. With no next item, Tab leaves the bar as usual. */
if (index !== links.length - 1) break;
const controls = topLevelControls();
const trigger = triggerFor(groupId);
const next = trigger ? controls[controls.indexOf(trigger) + 1] : undefined;
if (next) {
event.preventDefault();
next.focus();
}
break;
}
default:
break;
}
@@ -264,29 +362,26 @@ export function DesktopNav({
/* Escape is handled once, on the bar, so it works from a trigger and from
inside a column. Focus goes back to the trigger that owns the column. */
const handleNavKeyDown = (event: ReactKeyboardEvent<HTMLElement>) => {
if (event.key !== "Escape" || !open) return;
if (event.key !== "Escape" || openGroup === null) return;
event.preventDefault();
const active = document.activeElement as HTMLElement | null;
const groupId =
active?.closest<HTMLElement>("[data-nav-column]")?.dataset.navColumn ??
active?.dataset.navTrigger;
closeMega();
if (groupId) triggerFor(groupId)?.focus();
const groupId = openGroup;
closeGroup();
triggerFor(groupId)?.focus();
};
/* React's onBlur is focusout, so it fires for descendants too. */
const handleNavBlur = (event: ReactFocusEvent<HTMLElement>) => {
const next = event.relatedTarget as Node | null;
if (!next || !navRef.current?.contains(next)) closeMega();
if (!next || !navRef.current?.contains(next)) closeGroup();
};
const handleItemPointerEnter = (
event: ReactPointerEvent<HTMLLIElement>,
isGroup: boolean,
groupId: string | null,
) => {
if (event.pointerType !== "mouse") return;
if (isGroup) openMega();
else closeMega();
if (groupId) showGroup(groupId);
else closeGroup();
};
const handleNavPointerLeave = (event: ReactPointerEvent<HTMLElement>) => {
@@ -298,7 +393,7 @@ export function DesktopNav({
<nav
ref={navRef}
aria-label={strings.primaryNavLabel}
data-mega-open={open ? "true" : "false"}
data-mega-open={openGroup !== null ? "true" : "false"}
className={cx("relative", className)}
onPointerEnter={clearCloseTimer}
onPointerLeave={handleNavPointerLeave}
@@ -314,128 +409,132 @@ export function DesktopNav({
return (
<li
key={item.id}
className="flex"
onPointerEnter={(event) => handleItemPointerEnter(event, group)}
className="relative flex"
onPointerEnter={(event) =>
handleItemPointerEnter(event, group ? item.id : null)
}
>
{group ? (
<button
type="button"
id={triggerId(item.id)}
data-nav-top=""
data-nav-trigger={item.id}
data-current={hasCurrentChild ? "true" : undefined}
aria-expanded={open}
aria-controls={panelId(item.id)}
onFocus={openMega}
onClick={() => (open ? closeMega() : openMega())}
onKeyDown={(event) => handleTriggerKeyDown(event, item.id)}
className={cx(
"body-md-bold cursor-pointer whitespace-nowrap pb-[2px]",
"text-start text-[var(--text-primary)]",
"hover:text-[var(--text-accent)]",
"data-[current=true]:text-[var(--text-accent)]",
HOVER_TRANSITION,
FOCUS_RING,
)}
>
{item.label}
</button>
<>
<button
type="button"
id={triggerId(item.id)}
data-nav-top=""
data-nav-trigger={item.id}
data-current={hasCurrentChild ? "true" : undefined}
aria-expanded={openGroup === item.id}
aria-controls={panelId(item.id)}
onFocus={() => {
/* Moving to another trigger closes the one left behind. */
if (openGroup !== null && openGroup !== item.id) closeGroup();
}}
onPointerDown={(event) => {
lastPointerType.current = event.pointerType;
}}
onClick={() => handleTriggerClick(item.id)}
onKeyDown={(event) => handleTriggerKeyDown(event, item.id)}
className={cx(
"body-sm-bold flex cursor-pointer items-center gap-2xs",
"whitespace-nowrap pb-[2px] text-start",
"text-[var(--text-primary)]",
"hover:text-[var(--text-accent)]",
"aria-expanded:text-[var(--text-accent)]",
"data-[current=true]:text-[var(--text-accent)]",
HOVER_TRANSITION,
FOCUS_RING,
)}
>
<span>{item.label}</span>
<Chevron />
</button>
{/* The section the reader is inside keeps a 2px accent rule
under its trigger, as the draft draws it. Decorative -
`data-current` on the trigger carries the state. */}
{hasCurrentChild ? (
<span
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-[2px] bg-[var(--surface-accent)]"
/>
) : null}
{/* The draft's dropdown is a compact card hanging from its
own trigger, not a full-width mega bar. `start-[-1rem]`
pulls the card back by its own padding so a child label
lines up with the trigger label above it; being a logical
inset, it mirrors in Arabic on its own. */}
<ul
id={panelId(item.id)}
aria-labelledby={triggerId(item.id)}
hidden={openGroup !== item.id}
className={cx(
"sh-nav-menu absolute top-full start-[-1rem] z-[var(--layer-dropdown)]",
"mt-sm flex min-w-[17.875rem] flex-col",
"rounded-control border border-[var(--border-subtle)]",
"bg-[var(--surface-raised)] p-xs",
"shadow-[var(--elevation-popover)]",
)}
>
{item.children.map((child) => (
<li key={child.id}>
<Link
to={child.href}
data-nav-sublink=""
aria-current={
isCurrentPath(child.href, pathname) ? "page" : undefined
}
onKeyDown={(event) => handleSubLinkKeyDown(event, item.id)}
className={cx(
"body-sm-bold flex items-center justify-between gap-md",
"rounded-control px-sm py-xs no-underline",
"text-[var(--text-primary)]",
"hover:bg-[var(--surface-page)] hover:text-[var(--text-accent)]",
"aria-[current=page]:text-[var(--text-accent)]",
HOVER_TRANSITION,
FOCUS_RING,
)}
>
<span>{child.label}</span>
<MenuArrow />
</Link>
</li>
))}
</ul>
</>
) : (
<Link
to={item.href}
data-nav-top=""
aria-current={
isCurrentPath(item.href, pathname) ? "page" : undefined
}
onFocus={closeMega}
className={cx(
"body-md-bold whitespace-nowrap pb-[2px] no-underline",
"text-[var(--text-primary)]",
"hover:text-[var(--text-accent)]",
"aria-[current=page]:text-[var(--text-accent)]",
"aria-[current=page]:underline aria-[current=page]:decoration-2",
"aria-[current=page]:underline-offset-4",
HOVER_TRANSITION,
FOCUS_RING,
)}
>
{item.label}
</Link>
<>
<Link
to={item.href}
data-nav-top=""
aria-current={
isCurrentPath(item.href, pathname) ? "page" : undefined
}
onFocus={closeGroup}
className={cx(
"body-sm-bold whitespace-nowrap pb-[2px] no-underline",
"text-[var(--text-primary)]",
"hover:text-[var(--text-accent)]",
"aria-[current=page]:text-[var(--text-accent)]",
HOVER_TRANSITION,
FOCUS_RING,
)}
>
{item.label}
</Link>
{isCurrentPath(item.href, pathname) ? (
<span
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-[2px] bg-[var(--surface-accent)]"
/>
) : null}
</>
)}
</li>
);
})}
</ul>
{/* Mega panel. Anchored to the bar's inline start so the columns share the
trigger row's origin and gap; `start-0` mirrors on its own in Arabic. */}
<div hidden={!open} className="absolute start-0 top-full w-full">
{/* One full-bleed backdrop for all columns, so the surface and its
bottom edge are a single unbroken rectangle. The header clips the
horizontal bleed with overflow-x-clip, which leaves the vertical
overflow visible. */}
<div
aria-hidden="true"
className={cx(
"absolute inset-y-0 start-[-50vw] end-[-50vw]",
"border-b border-[var(--border-subtle)] bg-[var(--surface-page)]",
)}
/>
<div className={cx("relative flex pb-lg", ROW_GAP)}>
{items.map((item) => (
<div
key={item.id}
data-nav-column={item.id}
className="flex flex-col"
>
{/* Width setter: an invisible copy of the trigger label in the
same type role, so this column is exactly as wide as its
trigger. It is h-0, so it adds no height. */}
<span
aria-hidden="true"
className="body-md-bold invisible block h-0 overflow-hidden whitespace-nowrap"
>
{item.label}
</span>
{isNavGroup(item) && (
/* w-0 keeps the list out of the column's max-content width;
min-w-full then stretches it back, so long child labels wrap
inside the trigger's width exactly as they do in Figma. */
<ul
id={panelId(item.id)}
aria-labelledby={triggerId(item.id)}
className="flex w-0 min-w-full flex-col gap-sm pt-md"
>
{item.children.map((child) => (
<li key={child.id}>
<Link
to={child.href}
data-nav-sublink=""
aria-current={
isCurrentPath(child.href, pathname) ? "page" : undefined
}
onKeyDown={(event) => handleSubLinkKeyDown(event, item.id)}
className={cx(
"body-md block no-underline",
"text-[var(--text-secondary)]",
"hover:text-[var(--text-primary)]",
"aria-[current=page]:text-[var(--text-accent)]",
HOVER_TRANSITION,
FOCUS_RING,
)}
>
{child.label}
</Link>
</li>
))}
</ul>
)}
</div>
))}
</div>
</div>
</nav>
);
}
@@ -1,73 +0,0 @@
import { getDirection, type Locale } from "../LanguageSwitcher";
import { cx } from "./styles";
import type { NavSurface } from "./types";
/* Exported from Figma assets/eu-en (224:11275) and assets/eu-en - darkBg
(238:2325). The emblem is drawn at the prescribed 48x32 on both surfaces;
the source files are downscaled copies of the Figma exports. */
const EMBLEM_SRC: Record<NavSurface, string> = {
light: "/assets/eu-flag.png",
dark: "/assets/eu-flag-dark.png",
};
const EMBLEM_INTRINSIC_WIDTH = 192;
const EMBLEM_INTRINSIC_HEIGHT = 128;
export interface EuBadgeProps {
/** "Funded by the European Union", already in the reader's language. */
statement: string;
/** Alt text for the emblem. Empty when the statement is rendered beside it. */
emblemAlt?: string;
/** Language of `statement` - drives the font switch and the text direction. */
locale: Locale;
surface: NavSurface;
className?: string;
}
/**
* The mandatory EU visibility lockup: emblem + funding statement.
*
* The lockup itself is pinned to `dir="ltr"` so the emblem always leads, which
* is how the Arabic navigation frame (334:526) draws it; only the statement
* carries the reader's own direction. The bar's own mirroring still moves the
* whole badge from the top right (English) to the top left (Arabic).
*
* DEVIATION FROM FIGMA: the design sets the statement in EU reflex blue
* (hex 0f2a8e). A raw hex is not permitted in a component here, and there is
* no semantic token for a third-party brand colour, so the statement uses
* --text-primary. It stays legible on both surfaces and the emblem still
* carries the EU colours. Add a tier-2 token if the blue has to come back.
*/
export function EuBadge({
statement,
emblemAlt = "",
locale,
surface,
className,
}: EuBadgeProps) {
return (
<div
dir="ltr"
className={cx("flex shrink-0 items-center gap-2xs", className)}
>
<img
src={EMBLEM_SRC[surface]}
alt={emblemAlt}
width={EMBLEM_INTRINSIC_WIDTH}
height={EMBLEM_INTRINSIC_HEIGHT}
loading="eager"
decoding="async"
draggable={false}
className="block h-[32px] w-[48px] shrink-0"
/>
<span
lang={locale}
dir={getDirection(locale)}
className="label-md block w-[96px] text-[var(--text-primary)]"
>
{statement}
</span>
</div>
);
}
@@ -8,10 +8,10 @@ import {
} from "react";
import { Link } from "react-router";
import { EuBadge } from "../EuBadge";
import { LanguageSwitcher, isCurrentPath, type Locale } from "../LanguageSwitcher";
import { Brand } from "./Brand";
import { EuBadge } from "./EuBadge";
import { MenuBars } from "./MenuBars";
import { cx, FOCUS_RING, HOVER_TRANSITION } from "./styles";
import { isNavGroup, type NavItem, type NavigationStrings, type NavSurface } from "./types";
@@ -247,7 +247,9 @@ export function MobileNav({
"bg-[var(--surface-page)] xl:hidden",
)}
>
<div className="flex min-h-[64px] shrink-0 items-center justify-between gap-md px-[var(--layout-gutter)]">
{/* Mirrors the compact bar it replaces (20:220): the same 64px row and
the same gutter, so the wordmark does not shift when it opens. */}
<div className="flex h-[64px] shrink-0 items-center justify-between gap-md px-[var(--layout-gutter)]">
<Brand href={homeHref} label={strings.homeLinkLabel} surface={surface} />
<button
ref={closeRef}
@@ -0,0 +1,33 @@
/* ============================================================================
NAVIGATION - the dropdown's opening motion
----------------------------------------------------------------------------
The redesign opens each group's menu with a short fade and an 8px settle
("menuIn" in the draft). It lives here rather than in the page kit because
the navigation is a design-system component and must not depend on a route
module's stylesheet.
`:not([hidden])` is what replays it: the panel is hidden with the `hidden`
attribute, so the animation runs each time the group opens instead of once
per mount. app.css collapses it to 0.01ms under prefers-reduced-motion.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
.sh-nav-menu:not([hidden]) {
animation: sh-nav-menu-in var(--motion-duration-base)
var(--motion-ease-entrance) both;
}
}
@keyframes sh-nav-menu-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: none;
}
}
@@ -3,13 +3,12 @@ import { useMemo } from "react";
import {
buildLocalizedHref,
getDirection,
LanguageSwitcher,
LanguageToggle,
type Locale,
} from "../LanguageSwitcher";
import { Brand } from "./Brand";
import { DesktopNav } from "./DesktopNav";
import { EuBadge } from "./EuBadge";
import { MobileNav } from "./MobileNav";
import { getDefaultNavItems, getNavigationStrings } from "./nav-items";
import { cx } from "./styles";
@@ -36,15 +35,6 @@ export interface NavigationProps {
/** Where the logo links. Defaults to the locale home, "/en" or "/ar". */
homeHref?: string;
showLanguageSwitcher?: boolean;
/**
* Show the EU badge in the compact bar below xl as well. ON by default:
* donor visibility is non-negotiable, and the Figma mobile frame (20:220),
* which is logo + hamburger only, would leave the badge out of the header on
* every phone, tablet and 1280px-narrow laptop. Below xl the lockup renders
* at a reduced size rather than being dropped. Pass `false` only for a
* surface where the funding statement is guaranteed elsewhere on screen.
*/
showEuBadgeOnMobileBar?: boolean;
/** Stick the bar to the top of the viewport. */
sticky?: boolean;
className?: string;
@@ -53,13 +43,19 @@ export interface NavigationProps {
/**
* The SHARP-ER site navigation - Figma frame 20:236.
*
* Logo at the inline start, the mandatory "Funded by the European Union" badge
* at the inline end, and the mega-menu groups between them. Everything is laid
* out with logical properties, so the Arabic bar mirrors (logo right, EU badge
* left) from `<html dir="rtl">` alone - there is no separate Arabic component.
* Three slots, spread across the bar: the wordmark at the inline start, the
* mega-menu groups in the middle, and the language globe at the inline end.
* Everything is laid out with logical properties, so the Arabic bar mirrors
* (logo right, globe left) from `<html dir="rtl">` alone - there is no separate
* Arabic component, which is what variants 334:526 and 334:764 show.
*
* Below the xl breakpoint the bar collapses to logo + hamburger and the whole
* tree moves into a full-screen drawer.
* Below the xl breakpoint the bar collapses to wordmark + hamburger (20:220)
* and the whole tree moves into a full-screen drawer.
*
* EU VISIBILITY: the updated design has no funding badge in the bar - the
* Actions slot holds the language globe alone. The mandatory "Funded by the
* European Union" lockup is carried by <Footer />, which renders on every page,
* and by the mobile drawer; it is not dropped from the site.
*/
export function Navigation({
locale = "en",
@@ -71,7 +67,6 @@ export function Navigation({
strings: stringOverrides,
homeHref,
showLanguageSwitcher = true,
showEuBadgeOnMobileBar = true,
sticky = true,
className,
}: NavigationProps) {
@@ -90,10 +85,10 @@ export function Navigation({
return (
<header
data-surface={surface}
/* overflow-x-clip contains the mega panel's full-bleed backdrop without
clipping the panel itself, which overflows vertically. */
className={cx(
"w-full overflow-x-clip border-b border-[var(--border-subtle)]",
"w-full border-b border-[var(--border-subtle)]",
/* Translucent ground + blur, as the drafts draw the bar. */
"bg-[var(--surface-nav)] backdrop-blur-[14px]",
sticky && "sticky top-0 z-[var(--layer-header)]",
className,
)}
@@ -101,10 +96,13 @@ export function Navigation({
<div
className={cx(
"mx-auto flex w-full max-w-[var(--layout-content-max)] items-center justify-between",
/* The compact bar carries wordmark + EU lockup + hamburger on a
320px screen, so it runs a tighter gap than the desktop bar. */
"min-h-[64px] gap-xs px-[var(--layout-gutter)]",
"xl:min-h-[72px] xl:gap-lg xl:px-2xl",
/* Mobile 20:220 is a fixed 64px bar; the gutter is the site's own
rather than the frame's 20px, so the wordmark lines up with the
page content underneath it. */
"h-[64px] gap-md px-[var(--layout-gutter)]",
/* The drafts pad the bar with the same gutter every section uses, so
the wordmark sits directly above the page's content edge. */
"xl:h-auto xl:gap-lg xl:px-[var(--layout-gutter-lg)] xl:py-sm",
)}
>
<Brand
@@ -121,43 +119,33 @@ export function Navigation({
className="hidden xl:block"
/>
<div className="flex items-center gap-xs xl:gap-md">
{/* Desktop-only cluster. Visibility lives on this wrapper rather than
on the children: two display utilities on one element are resolved
by the order Tailwind emits them, not by class-attribute order. */}
<div className="hidden items-center gap-md xl:flex">
{showLanguageSwitcher && (
<LanguageSwitcher
<div className="flex items-center gap-md">
{/* The globe is desktop-only: the mobile bar (20:220) is wordmark +
hamburger, and the drawer carries the switcher instead. Visibility
lives on this wrapper rather than on the child, because two
display utilities on one element are resolved by the order
Tailwind emits them, not by class-attribute order. */}
{showLanguageSwitcher && (
/* Figma 555:3848 sets a 20px rule between the links and the
actions, then 24px to the globe. It is decorative, so it is
hidden from assistive technology and drawn with a border token
rather than a rule element. */
<div className="hidden items-center gap-lg xl:flex">
<span
aria-hidden="true"
className="h-5 w-px bg-[var(--border-default)]"
/>
{/* Both languages side by side, per the redesign's nav: the
current one chipped, the other one link. */}
<LanguageToggle
label={strings.languageMenuLabel}
locale={locale}
pathname={pathname}
search={search}
hash={hash}
/>
)}
{!showEuBadgeOnMobileBar && (
<EuBadge
statement={strings.euStatement}
emblemAlt={strings.euEmblemAlt}
locale={locale}
surface={surface}
/>
)}
</div>
{showEuBadgeOnMobileBar && (
<EuBadge
statement={strings.euStatement}
emblemAlt={strings.euEmblemAlt}
locale={locale}
surface={surface}
/* Below xl the lockup is scaled down - emblem 36x24 instead of
48x32, statement wrapped in a narrower box - so it still fits
beside the wordmark and the hamburger at 320px. It is scaled,
never hidden: the statement stays at Label/Medium, i.e. the
same size it is everywhere else, so it remains legible. */
className="max-xl:[&>img]:h-[24px] max-xl:[&>img]:w-[36px] max-xl:[&>span]:w-[80px]"
/>
</div>
)}
<MobileNav
@@ -204,8 +192,4 @@ export default Navigation;
// keeps the query string and fragment:
const { pathname, search, hash } = useLocation();
<Navigation locale={locale} pathname={pathname} search={search} hash={hash} />
// The EU badge is in the compact bar by default. Opt out only where the
// funding statement is guaranteed to be visible elsewhere on the screen:
<Navigation locale="en" pathname="/en" showEuBadgeOnMobileBar={false} />
------------------------------------------------------------------------ */
@@ -5,8 +5,6 @@ export { Brand } from "./Brand";
export type { BrandProps } from "./Brand";
export { DesktopNav } from "./DesktopNav";
export type { DesktopNavProps } from "./DesktopNav";
export { EuBadge } from "./EuBadge";
export type { EuBadgeProps } from "./EuBadge";
export { MenuBars } from "./MenuBars";
export type { MenuBarsProps } from "./MenuBars";
export { MobileNav } from "./MobileNav";
@@ -132,6 +132,7 @@ export const NAVIGATION_STRINGS: NavigationStringsByLocale = {
openMenu: "Open menu",
closeMenu: "Close menu",
homeLinkLabel: "SHARP-ER home",
languageMenuLabel: "Language",
euStatement: "Funded by the European Union",
euEmblemAlt: "",
},
@@ -140,6 +141,7 @@ export const NAVIGATION_STRINGS: NavigationStringsByLocale = {
openMenu: "فتح القائمة",
closeMenu: "إغلاق القائمة",
homeLinkLabel: "الصفحة الرئيسية لمشروع SHARP-ER",
languageMenuLabel: "اللغة",
euStatement: "بتمويل من الاتحاد الأوروبي",
euEmblemAlt: "",
},
@@ -41,6 +41,8 @@ export interface NavigationStrings {
closeMenu: string;
/** Accessible name of the logo link. */
homeLinkLabel: string;
/** Accessible name of the language globe, which carries no text of its own. */
languageMenuLabel: string;
/** The mandatory EU funding statement. */
euStatement: string;
/**
+1
View File
@@ -16,6 +16,7 @@
"openMenu": "فتح القائمة",
"closeMenu": "إغلاق القائمة",
"homeLinkLabel": "الصفحة الرئيسية لمشروع SHARP-ER",
"languageMenu": "اللغة",
"skipToContent": "تخطَّ إلى المحتوى",
"sections": {
"home": "الرئيسية",
+1
View File
@@ -16,6 +16,7 @@
"openMenu": "Open menu",
"closeMenu": "Close menu",
"homeLinkLabel": "SHARP-ER home",
"languageMenu": "Language",
"skipToContent": "Skip to content",
"sections": {
"home": "Home",
+13
View File
@@ -0,0 +1,13 @@
/* ============================================================================
SITE LINKS - the project's own channels
----------------------------------------------------------------------------
One place for the URLs the site itself owns, so the footer and the contact
page cannot drift apart. The CMS still wins wherever it supplies a value;
these are the bundled fallbacks.
A channel goes in here ONLY once it is real and confirmed. The approved
draft writes the Facebook page as "[Facebook page link placeholder]"; the
address below was confirmed by the project on 2026-09-16.
========================================================================= */
export const SITE_FACEBOOK_URL = "https://www.facebook.com/sharperlibya";
+136 -15
View File
@@ -21,6 +21,16 @@ import type { Locale } from "~/i18n";
const EN = {
events: {
/* The kicker above an event title. "other" is intentionally empty: an
event the CMS did not classify simply gets no kicker. */
eventTypes: {
"suq-dialogue": "SUQ Dialogue",
training: "Training",
"law-session": "Law Session",
"network-meeting": "Network Meeting",
launch: "Launch",
other: "",
},
/** Draft 4, EN headline. */
heading: "Upcoming Events",
/** Draft 4, EN standfirst. */
@@ -79,6 +89,9 @@ const EN = {
metaDescription:
"Calls for expression of interest, SHARP network membership and upcoming sessions — how civil society organisations and individuals take part in SHARP-ER.",
pathwaysHeading: "Ways to take part",
/* The draft has no call list; this single line keeps the open calls
reachable in-site now that the listing band is gone. */
currentCallsLabel: "Open calls:",
pathwayCallsTitle: "Apply for a Call for Expression of Interest",
pathwayCallsBody:
"Open calls for CSO sub-projects and Policy-to-Rights (P2R) initiatives.",
@@ -88,14 +101,81 @@ const EN = {
pathwaySessionsTitle: "Register for a SUQ Dialogue or Training",
pathwaySessionsBody:
"Take part in an upcoming Search, Understand, Question dialogue or a dialogue-facilitation training.",
listHeading: "Current calls",
listLabel: "Calls and opportunities",
statusFilterLabel: "Status",
empty: "There are no calls or opportunities to show yet.",
emptyHint:
"Calls for expression of interest are published here as soon as they open.",
unavailable:
"Calls and opportunities cannot be loaded right now. Please try again in a moment.",
},
/* -------------------------------------------------------------------------
THE APPLICATION FORM - the redesign draft's #apply band
----------------------------------------------------------------------------
THREE OF THE DRAFT'S SENTENCES ARE NOT REPRODUCED, on the client's own
instruction and under CLAUDE.md rule 5:
* "PDF or DOCX, up to 10 MB" - not the rule this project runs. The CMS
ceiling is UPLOAD_MAX_FILE_SIZE (50 MB by default) over the media types
listed in apps/cms/config/plugins.ts, and in any case nothing is
uploaded here (see `documentsNote`), so no limit is quoted at all.
* "an automatic acknowledgment with your reference number" - there is no
reference-number field anywhere in the CMS, so no number is promised.
The acknowledgement itself IS modelled, as
`application.acknowledgementSentAt`, so it is the only half that
survives.
* "follows up within four weeks" - that response time appears in no
approved document; §8 of the draft marks it "[X weeks] - TBD". The
follow-up is stated without a period attached, exactly as the per-call
acknowledgement already does.
---------------------------------------------------------------------- */
apply: {
/** The draft's "Step {n} of 3" kicker over the form. */
stepOf: "Step {current} of {total}",
/** Accessible name of the progress list. */
stepsLabel: "Application progress",
stepEligibility: "Eligibility",
stepApplication: "Application",
stepReview: "Review",
/** Announced on the step the reader is on. */
stepCurrent: "current step",
stepDone: "done",
/** The standfirst beside the form. */
standfirst:
"Submissions are acknowledged automatically by email. The consortium reviews complete applications and follows up by email.",
formLabel: "Application form",
fieldsetLegend: "Your details",
routeLabel: "What are you applying for?",
/* The short names the draft gives the three routes - the long ones head
the pathway cards above and are too long for a select. */
routeCalls: "Call for Expression of Interest",
routeNetwork: "SHARP Network membership",
routeSessions: "SUQ dialogue or training",
organisationLabel: "Organisation name",
organisationPlaceholder: "Registered legal name",
contactLabel: "Contact person",
contactPlaceholder: "Full name",
emailLabel: "Email",
emailPlaceholder: "name@organisation.ly",
regionLabel: "Region of operation",
regionPlaceholder: "City or municipality",
yearsLabel: "Years of operating history",
yearsPlaceholder: "e.g. 4",
summaryLabel: "Summary of your proposal",
summaryHint: "Briefly describe your organisation and why you are applying.",
documentsTitle: "Supporting documents",
/**
* The draft draws a file input here. There is nowhere for the file to go:
* `attachments` is deliberately off the public whitelist in
* apps/cms/src/api/application/middlewares/sanitize-submission.ts, and the
* Public role cannot upload. A control that accepted a document and then
* dropped it would be worse than none, so the panel says plainly what the
* form does with documents - and quotes no size limit, because it accepts
* no files at all.
*/
documentsNote:
"Documents are not uploaded through this form. Describe any supporting material in the summary above.",
/** `application.acknowledgementSentAt` - modelled, so it can be said. */
acknowledgement: "Your submission is acknowledged automatically by email.",
submit: "Submit application",
errorSummaryTitle: "Please check the form",
receivedTitle: "Application received",
receivedBody:
"Thank you for applying to {route}. An acknowledgement is on its way to the email address you gave us, and our team will review your application and follow up by email.",
submitAnother: "Submit another",
},
/** `opportunity.opportunityType`, verbatim, with the draft's own wording. */
opportunityTypes: {
@@ -143,6 +223,14 @@ export type RouteCopy = typeof EN;
const AR: RouteCopy = {
events: {
eventTypes: {
"suq-dialogue": "حوار سوق",
training: "تدريب",
"law-session": "جلسة قانونية",
"network-meeting": "اجتماع الشبكة",
launch: "إطلاق",
other: "",
},
heading: "الفعاليات القادمة",
intro:
"سجّل في حوار SUQ، أو انضم إلى تدريب، أو تابع ما هو قادم في مختلف أنحاء ليبيا.",
@@ -189,6 +277,7 @@ const AR: RouteCopy = {
metaDescription:
"دعوات للتعبير عن الاهتمام، وعضوية شبكة SHARP، وجلسات قادمة — كيف تشارك منظمات المجتمع المدني والأفراد في SHARP-ER.",
pathwaysHeading: "طرق المشاركة",
currentCallsLabel: "الدعوات المفتوحة:",
pathwayCallsTitle: "التقديم على دعوة للتعبير عن الاهتمام",
pathwayCallsBody:
"دعوات مفتوحة للمشاريع الفرعية لمنظمات المجتمع المدني ومبادرات «من السياسة إلى الحقوق» (P2R).",
@@ -198,13 +287,45 @@ const AR: RouteCopy = {
pathwaySessionsTitle: "سجّل في حوار SUQ أو تدريب",
pathwaySessionsBody:
"شارك في حوار قادم من نوع «ابحث، افهم، اسأل» أو في تدريب على تيسير الحوار.",
listHeading: "الدعوات الحالية",
listLabel: "الدعوات والفرص",
statusFilterLabel: "الحالة",
empty: "لا توجد دعوات أو فرص لعرضها حتى الآن.",
emptyHint: "تُنشر هنا دعوات التعبير عن الاهتمام فور فتحها.",
unavailable:
"تعذّر تحميل الدعوات والفرص في الوقت الحالي. يرجى المحاولة مرة أخرى بعد قليل.",
},
apply: {
stepOf: "الخطوة {current} من {total}",
stepsLabel: "مراحل تقديم الطلب",
stepEligibility: "الأهلية",
stepApplication: "الطلب",
stepReview: "المراجعة",
stepCurrent: "الخطوة الحالية",
stepDone: "مكتملة",
standfirst:
"يصلك إشعار تلقائي باستلام طلبك عبر البريد الإلكتروني. ويراجع الائتلاف الطلبات المكتملة ويتواصل معك عبر البريد الإلكتروني.",
formLabel: "نموذج التقديم",
fieldsetLegend: "بياناتك",
routeLabel: "ما الذي تقدّم عليه؟",
routeCalls: "دعوة للتعبير عن الاهتمام",
routeNetwork: "عضوية شبكة SHARP",
routeSessions: "حوار SUQ أو تدريب",
organisationLabel: "اسم المنظمة",
organisationPlaceholder: "الاسم القانوني المسجَّل",
contactLabel: "الشخص المسؤول",
contactPlaceholder: "الاسم الكامل",
emailLabel: "البريد الإلكتروني",
emailPlaceholder: "name@organisation.ly",
regionLabel: "نطاق العمل",
regionPlaceholder: "المدينة أو البلدية",
yearsLabel: "عدد سنوات العمل",
yearsPlaceholder: "مثال: 4",
summaryLabel: "ملخّص المقترح",
summaryHint: "صف باختصار منظمتك وسبب تقديمك.",
documentsTitle: "المستندات الداعمة",
documentsNote:
"لا تُرفَق المستندات عبر هذا النموذج. صف أي مواد داعمة في الملخّص أعلاه.",
acknowledgement: "يصلك إشعار تلقائي باستلام طلبك عبر البريد الإلكتروني.",
submit: "إرسال الطلب",
errorSummaryTitle: "يرجى مراجعة النموذج",
receivedTitle: "تم استلام الطلب",
receivedBody:
"شكرًا لتقديمك على {route}. سيصلك إشعار بالاستلام على البريد الإلكتروني الذي زوّدتنا به، وسيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.",
submitAnother: "تقديم طلب آخر",
},
opportunityTypes: {
"call-eoi": "دعوة للتعبير عن الاهتمام",
@@ -69,6 +69,8 @@ import {
type EventDetailData,
type EventFormat,
type EventRegistrationStatus,
EVENT_TYPES,
type EventType,
} from "./events";
export const EVENTS_RESOURCE = "events";
@@ -83,6 +85,7 @@ interface StrapiEvent extends StrapiEntity, CmsLocalizable {
readonly body?: CmsRichText;
readonly startDateTime: string;
readonly endDateTime?: string | null;
readonly eventType?: EventType | null;
readonly format?: EventFormat | null;
readonly venue?: string | null;
readonly region?: CmsTaxonomy | null;
@@ -135,6 +138,7 @@ function toCard(entry: StrapiEvent, now: Date): EventCardData {
start,
end,
allDay,
eventType: oneOf(entry.eventType, EVENT_TYPES),
format: oneOf(entry.format, FORMATS),
place: text(entry.venue),
region: text(entry.region?.name),
@@ -53,6 +53,22 @@ export type EventRegistrationStatus =
| "waitlist"
| "not-required";
/**
* api::event.eventType - what kind of session this is. The redesign prints it
* as a kicker above the title ("SUQ Dialogue", "Training"), so the label map
* lives with the rest of the route copy, in both locales.
*/
export const EVENT_TYPES = [
"suq-dialogue",
"training",
"law-session",
"network-meeting",
"launch",
"other",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
export const EVENT_REGISTRATION_STATUSES = [
"open",
"closed",
@@ -85,6 +101,8 @@ export function isEventStatusFilter(value: unknown): value is EventStatusFilter
export interface EventCardData {
readonly slug: string;
readonly title: string;
/** Kicker above the title. Absent when the CMS leaves the type unset. */
readonly eventType?: EventType;
readonly summary?: string;
/** ISO 8601. Date-only when `allDay`. */
readonly start: string;
@@ -54,6 +54,10 @@ const LIMITS = {
email: 254,
phone: 40,
message: 4000,
/** Free text - "Tripoli", "Sabha and the south". Not a taxonomy. */
region: 160,
/** Free text on purpose: "4", "under a year", "since 2016" are all answers. */
years: 40,
} as const;
function field(form: FormData, name: string, limit: number): string {
@@ -150,3 +154,103 @@ export function validateApplication(
return { values, errors, valid: Object.keys(errors).length === 0 };
}
/* ---------------------------------------------------------------------------
GET INVOLVED - the application form on the section's own page
----------------------------------------------------------------------------
The redesign draft puts a single application form under the three pathway
cards, with five fields and a proposal summary. It is the same contract as
the per-call form above, plus the two answers the draft asks for (region of
operation, years of operating history) and the pathway being applied under.
WHAT IS REQUIRED, AND WHY EXACTLY THOSE THREE
`application.applicantName` and `application.email` are `required: true` in
the CMS, so a submission without them is a 400 that costs the applicant
everything they typed - they are validated here first. The summary is
required because an application with no case in it cannot be reviewed.
Everything else is optional BY DESIGN, not by omission: the page invites
"a civil society organisation or an individual", so an organisation name, a
region and a number of years of operating history cannot be demanded
without shutting the individuals out of the form that invites them.
`yearsOperating` is a STRING, deliberately. "4", "under a year" and "since
2016" are all true answers to the draft's question, and a number input would
reject two of them; it reaches the CMS inside `answers`, where a reviewer
reads it, and nothing computes on it.
------------------------------------------------------------------------ */
/**
* The three ways in, as the draft names them. The value is carried in the
* query string (`?route=`) so a pathway card can open the form already set to
* its own route, and it is submitted as a real `<select>` so the reader can
* correct it - and so the choice still travels with scripting off.
*/
export type ApplicationRoute = "calls" | "network" | "sessions";
export const APPLICATION_ROUTES = [
"calls",
"network",
"sessions",
] as const satisfies readonly ApplicationRoute[];
export function isApplicationRoute(value: unknown): value is ApplicationRoute {
return (
typeof value === "string" &&
(APPLICATION_ROUTES as readonly string[]).includes(value)
);
}
export interface PathwayApplicationValues {
readonly route: ApplicationRoute;
readonly organisation: string;
readonly fullName: string;
readonly email: string;
readonly region: string;
readonly yearsOperating: string;
readonly summary: string;
}
/** The field name of the pathway select, shared by the form and the parser. */
export const ROUTE_FIELD = "route";
export function validatePathwayApplication(
form: FormData,
fallbackRoute: ApplicationRoute = "calls",
): Validated<PathwayApplicationValues> {
const submittedRoute = form.get(ROUTE_FIELD);
const values: PathwayApplicationValues = {
// An unknown value is not an error the reader can act on - it cannot be
// produced by the select - so it falls back rather than blocking a submit.
route: isApplicationRoute(submittedRoute) ? submittedRoute : fallbackRoute,
organisation: field(form, "organisation", LIMITS.organisation),
fullName: field(form, "fullName", LIMITS.name),
email: field(form, "email", LIMITS.email),
region: field(form, "region", LIMITS.region),
yearsOperating: field(form, "yearsOperating", LIMITS.years),
summary: field(form, "summary", LIMITS.message),
};
const errors: Record<string, FieldErrorCode> = {};
if (!values.fullName) errors.fullName = "required";
if (!values.email) errors.email = "required";
else if (!EMAIL.test(values.email)) errors.email = "email";
if (!values.summary) errors.summary = "required";
return { values, errors, valid: Object.keys(errors).length === 0 };
}
/** The form's fields in reading order - the error summary lists them in it. */
export const PATHWAY_APPLICATION_FIELDS = [
"organisation",
"fullName",
"email",
"region",
"yearsOperating",
"summary",
] as const;
export type PathwayApplicationField =
(typeof PATHWAY_APPLICATION_FIELDS)[number];
@@ -49,7 +49,11 @@
`findOne` are NOT, or anyone could read everyone else's contact details.
========================================================================= */
import type { ApplicationValues, RegistrationValues } from "./forms";
import type {
ApplicationValues,
PathwayApplicationValues,
RegistrationValues,
} from "./forms";
import { createStrapiEntry } from "./strapi-write.server";
export const EVENT_REGISTRATIONS_RESOURCE = "event-registrations";
@@ -104,3 +108,73 @@ export async function submitApplication(
answers: { [APPLICATION_MESSAGE_KEY]: input.values.message },
});
}
/* ---------------------------------------------------------------------------
GET INVOLVED - the section page's own application
------------------------------------------------------------------------ */
/**
* The keys the Get Involved form files inside `answers` (json).
*
* They are named here rather than spelled at the call site because a reviewer
* reads them in the admin panel: renaming one silently orphans every answer
* already stored under the old key.
*
* `message` is deliberately the SAME key the per-call form writes
* (APPLICATION_MESSAGE_KEY above), so the free-text case for applying reads
* out of one place whichever form it arrived through.
*/
export const APPLICATION_ANSWER_KEYS = {
/** Which of the three pathways the applicant chose. */
route: "route",
region: "regionOfOperation",
yearsOperating: "yearsOperating",
summary: APPLICATION_MESSAGE_KEY,
} as const;
export interface PathwayApplicationSubmission {
readonly values: PathwayApplicationValues;
/**
* documentId of the opportunity this application belongs to, when one is
* known. The Get Involved form is NOT tied to a single call - it is the
* section's general way in - so it is usually absent, and `opportunity` is
* optional on the content type precisely for that case. It is never guessed
* from "the nearest open call": an application filed against a call the
* applicant never named is a fabricated relation.
*/
readonly opportunityId?: string;
}
/**
* Write a Get Involved application. The payload is the CMS whitelist and
* nothing more (see the header): the two extra answers the draft asks for ride
* inside `answers`, which is on that whitelist, so they need no migration.
*
* `attachments` is NOT sent, and the form does not offer an upload. See the
* header of this file and the `sanitize-submission` middleware in the CMS for
* why a public caller cannot supply one.
*/
export async function submitPathwayApplication(
input: PathwayApplicationSubmission,
): Promise<void> {
const { values } = input;
await createStrapiEntry(APPLICATIONS_RESOURCE, {
...(input.opportunityId ? { opportunity: input.opportunityId } : {}),
applicantName: values.fullName,
email: values.email,
organisation: optional(values.organisation),
answers: {
[APPLICATION_ANSWER_KEYS.route]: values.route,
// Only the answers actually given: an empty string in the admin panel
// reads as "answered, with nothing", which is not what happened.
...(values.region
? { [APPLICATION_ANSWER_KEYS.region]: values.region }
: {}),
...(values.yearsOperating
? { [APPLICATION_ANSWER_KEYS.yearsOperating]: values.yearsOperating }
: {}),
[APPLICATION_ANSWER_KEYS.summary]: values.summary,
},
});
}
+133
View File
@@ -0,0 +1,133 @@
/* ============================================================================
PAGE KIT - FAQ accordion
----------------------------------------------------------------------------
The disclosure list the home page's FAQ band draws. One question is open at
a time, as the design draft has it, and the first one is open on arrival so
the band never reads as a row of empty bars.
PATTERN: a plain disclosure - a <button aria-expanded> that controls the
panel right after it. Deliberately NOT an ARIA accordion widget with arrow
key roving: there is one tab stop per question, which is what a keyboard
user expects from a list of links-like controls, and it matches the
disclosure the navigation already ships (DesktopNav, LanguageMenu).
The answer is CMS richtext (markdown), so it renders through <RichText />
rather than as a raw string.
RULES: tier-2 tokens only, logical properties only, and the open/close
motion is a class from motion.css so this module carries no keyframes.
========================================================================= */
import { useId, useState } from "react";
import { RichText } from "./rich-text";
import { cx } from "./sections";
import "./motion.css";
export interface AccordionItem {
readonly id: string;
readonly question: string;
/** Markdown, as authored in the CMS richtext field. */
readonly answer: string;
}
export interface AccordionProps {
items: readonly AccordionItem[];
/**
* Index of the question open on first paint, or null for an all-closed
* list. The server renders this same state, so nothing shifts on hydration.
*/
defaultOpenIndex?: number | null;
className?: string;
}
/** The +/- mark. The upright bar collapses as the question opens. */
function ToggleMark({ open }: { open: boolean }) {
return (
<span
aria-hidden="true"
className={cx(
"flex size-8 shrink-0 items-center justify-center rounded-pill",
"border border-[var(--border-default)] text-[var(--text-accent)]",
"transition-colors duration-[var(--motion-duration-base)]",
open && "border-[var(--border-accent)] bg-[var(--surface-accent-subtle)]",
)}
>
<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={cx(
"origin-center transition-transform duration-[var(--motion-duration-base)]",
open ? "scale-y-0" : "scale-y-100",
)}
/>
</svg>
</span>
);
}
export function Accordion({ items, defaultOpenIndex = 0, className }: AccordionProps) {
const baseId = useId();
const initial =
defaultOpenIndex === null ? null : (items[defaultOpenIndex]?.id ?? null);
const [openId, setOpenId] = useState<string | null>(initial);
if (items.length === 0) return null;
return (
<div className={cx("flex flex-col border-t border-[var(--border-default)]", className)}>
{items.map((item) => {
const open = item.id === openId;
const buttonId = `${baseId}-${item.id}-question`;
const panelId = `${baseId}-${item.id}-answer`;
return (
<div
key={item.id}
className={cx(
"border-b border-[var(--border-default)]",
"transition-colors duration-[var(--motion-duration-base)]",
"hover:bg-[var(--surface-muted)]",
)}
>
<button
type="button"
id={buttonId}
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpenId(open ? null : item.id)}
className={cx(
"flex w-full cursor-pointer items-center justify-between gap-lg",
"bg-transparent py-lg text-start",
"body-lg-bold text-[var(--text-primary)]",
"transition-colors duration-[var(--motion-duration-base)]",
"hover:text-[var(--text-accent)]",
)}
>
<span>{item.question}</span>
<ToggleMark open={open} />
</button>
{/* Kept out of the tree entirely while closed, so a screen reader
never reaches an answer whose question reads as collapsed. */}
{open ? (
<div
id={panelId}
role="region"
aria-labelledby={buttonId}
className="sh-answer pb-lg pe-3xl body-md text-[var(--text-secondary)]"
>
<RichText markdown={item.answer} />
</div>
) : null}
</div>
);
})}
</div>
);
}
@@ -103,6 +103,8 @@ export interface CmsPartner extends StrapiEntity {
export interface CmsConsortiumMember {
readonly id?: number;
readonly partner?: CmsPartner | null;
/** Short role chip - "Lead implementing partner", "Consortium partner". */
readonly role?: string | null;
readonly roleDescription?: string | null;
}
@@ -145,6 +147,27 @@ export function isRichTextBlock(block: CmsBlock): block is CmsRichTextBlock {
return block.__component === "blocks.rich-text";
}
/** api::news-article.news-article - the card shapes the home page needs. */
export interface CmsNewsArticle extends StrapiEntity {
readonly title: string;
readonly slug: string;
readonly excerpt?: string | null;
readonly category: "sharper-news" | "from-the-network";
/** Not localised in the CMS: one date for both languages. */
readonly publishDate: string;
readonly coverImage?: StrapiMedia | null;
readonly authorOrganisation?: Pick<CmsPartner, "name" | "slug"> | null;
readonly featured?: boolean | null;
}
/** api::faq.faq - one question of the home page's FAQ accordion. */
export interface CmsFaq extends StrapiEntity {
readonly question: string;
/** Markdown from the CMS richtext editor. */
readonly answer: string;
readonly order?: number | null;
}
/* ---------------------------------------------------------------------------
SINGLE TYPES
------------------------------------------------------------------------ */
@@ -154,6 +177,8 @@ export interface CmsHomePage extends StrapiEntity {
readonly hero?: CmsHero | null;
readonly stats?: readonly CmsStat[] | null;
readonly blocks?: readonly CmsBlock[] | null;
/** Curated newsroom picks; the page falls back to the latest articles. */
readonly featuredNews?: readonly CmsNewsArticle[] | null;
readonly partnersHeadline?: string | null;
readonly partners?: readonly CmsPartner[] | null;
readonly seo?: CmsSeo | null;
+234 -76
View File
@@ -44,10 +44,16 @@ export interface TitledCopy {
readonly description: string;
}
/** One consortium member and the role the draft assigns it. */
/**
* One consortium member, split the way the redesign draws it: a role chip, the
* member's name, then what it delivers. Both halves are the approved draft's
* own wording - section 1 writes each member as
* "<name> - <role> - <deliverable>".
*/
export interface ConsortiumCopy {
readonly name: string;
readonly role: string;
readonly description: string;
}
/**
@@ -59,47 +65,105 @@ export type ProseRun = string | { readonly em: string };
export interface HomeCopy {
readonly heroHeadline: string;
readonly statsHeading: string;
readonly heroLead: string;
readonly heroCtaPrimary: string;
readonly heroCtaSecondary: string;
/**
* The figures band. Bundled so the band is never empty, and overridden by
* `home-page.stats` the moment an editor writes one. Every figure here is
* verifiable: three consortium members and the Dec 2025 - Dec 2027 run come
* from the project brief, the four areas of work are the What We Do
* workstreams, and the sixteen human security sub-projects are the approved
* EN/AR draft's own wording.
*/
readonly stats: readonly { readonly value: string; readonly label: string }[];
readonly launchEyebrow: string;
readonly launchHeading: string;
/** One paragraph, as the design draws it - not the full three-part story. */
readonly launchLead: readonly ProseRun[];
readonly mechanismsHeading: string;
readonly mechanisms: readonly string[];
readonly partnersHeading: string;
readonly launchLead: string;
readonly workHeading: string;
readonly workLinkLabel: string;
readonly newsEyebrow: string;
readonly newsHeading: string;
readonly newsEmpty: string;
readonly faqEyebrow: string;
readonly faqHeading: string;
readonly faqLead: string;
readonly faqEmpty: string;
readonly faqCtaLabel: string;
readonly consortiumHeading: string;
/**
* The same three members the About page lists, split the way the redesign
* draws them: a role chip, the name, then what they deliver.
*/
readonly consortium: readonly {
readonly role: string;
readonly name: string;
readonly description: string;
}[];
readonly networkLabel: string;
readonly partnersEmpty: string;
}
export interface AboutCopy {
/** Draft eyebrow over the hero headline - "About the project". */
readonly heroEyebrow: string;
readonly heroHeadline: string;
readonly lead: string;
readonly body: string;
/**
* The four-cell band under the hero. These are project FACTS, not figures:
* the run, the coverage, the lead partner and the funder, each one written
* out in the approved draft's first section. Nothing here is counted or
* estimated - the draft marks every statistic "TBD", so none is published.
*/
readonly facts: readonly { readonly label: string; readonly value: string }[];
readonly bodyHeading: string;
/**
* The approved body prose, one entry per paragraph. The draft sets it as two
* paragraphs beside the focus-area column, which a single string cannot
* express.
*/
readonly body: readonly string[];
readonly consortiumHeading: string;
readonly consortium: readonly ConsortiumCopy[];
readonly pillarsHeading: string;
readonly pillars: readonly TitledCopy[];
readonly legacyEyebrow: string;
readonly legacyHeading: string;
readonly legacyBody: string;
readonly legacyLinkLabel: string;
readonly fundingNote: string;
}
export interface WhatWeDoCopy {
readonly heroHeadline: string;
readonly lead: string;
/**
* The draft's own heading over the areas grid. It restates the approved
* "SHARP-ER is organised around four connected areas of work" line, which is
* why the draft prints no separate intro paragraph under it and neither does
* the page.
*/
readonly workstreamsHeading: string;
readonly workstreamsIntro: string;
readonly workstreams: readonly TitledCopy[];
readonly valuesLabel: string;
readonly values: readonly string[];
/** Not drawn - it names the principles block for the document outline. */
readonly principlesHeading: string;
readonly principles: string;
/**
* The redesign draws the four principles as tiles. These are the four
* clauses of the approved "Our approach rests on four principles…" sentence
* and nothing else: the draft gives each tile an explanatory line, none of
* which is in any approved document, so the tiles carry the clause alone.
*/
readonly principleTitles: readonly string[];
readonly activitiesCtaHeading: string;
}
export interface ContactCopy {
readonly heroHeadline: string;
readonly lead: string;
/** Not drawn - it names the form for the document outline. */
readonly formHeading: string;
readonly formIntro: string;
/** Not drawn - it names the channels column for the document outline. */
readonly detailsHeading: string;
readonly generalEnquiries: string;
readonly websiteLabel: string;
@@ -113,6 +177,8 @@ export interface ContactCopy {
readonly errorTooLong: string;
readonly honeypotLabel: string;
readonly organisationHint: string;
/** Link text for the project's Facebook page. */
readonly facebookLabel: string;
}
export interface PageCopy {
@@ -128,50 +194,89 @@ export interface PageCopy {
const EN: PageCopy = {
home: {
/* The frame's wordmark above the headline carries the project name, so
the headline itself does not repeat it (figma I253:4800;228:2609). */
heroHeadline: "Project Officially Launches in Libya",
statsHeading: "SHARP-ER in numbers",
launchHeading: "The Journey of SHARP-ER Launch",
launchLead: [
"The SHARP-ER project officially launched on ",
{ em: "Tuesday, June 30" },
", joining representatives from the ",
{ em: "European Union Delegation" },
", key ",
{ em: "Libyan Ministries" },
", and ",
{ em: "local civil society partners" },
". The event marked the launch of the project’s official website and the Call for Expression of Interest (EOI).",
heroHeadline: "SHARP-ER Project Officially Launches in Libya",
heroLead:
"Shared Action on Rule of Law Progress — Exit Route: a nationwide initiative supporting Libya’s civil society, local institutions, and communities in advancing justice, accountability, and good governance.",
heroCtaPrimary: "Expression of Interest",
heroCtaSecondary: "About the project",
stats: [
{ value: "3", label: "Consortium partners" },
{ value: "4", label: "Areas of work" },
{ value: "16", label: "Human security sub-projects" },
{ value: "25 mo", label: "Dec 2025 – Dec 2027" },
],
mechanismsHeading: "What the launch set in motion",
mechanisms: [
"Building local capacity and skills",
"Fostering cross-sector partnerships",
"Supporting municipal and community-led initiatives",
launchEyebrow: "30 June · Launch event",
launchHeading: "An important start for the project",
launchLead:
"The launch brought together representatives from the European Union Delegation, the Ministry of Youth, the Ministry of Local Governance, the Ministry of Women’s Affairs, civil society organisations, partners, and stakeholders from across Libya. The event marked the opening of the Expression of Interest and the launch of this website.",
workHeading: "Three areas of change",
workLinkLabel: "What we do",
newsEyebrow: "Newsroom",
newsHeading: "News and Updates",
newsEmpty: "News and updates will appear here as they are published.",
faqEyebrow: "Frequently asked",
faqHeading: "Questions about the project",
/* The draft also promises a reply "within five working days". That
commitment is in no approved document, so it is not published here. */
faqLead: "Eligibility, timelines and how to take part.",
faqEmpty: "Answers to common questions will be published here.",
faqCtaLabel: "More in the Knowledge Hub",
consortiumHeading: "The consortium",
consortium: [
{
role: "Lead implementing partner",
name: "Acted",
description:
"Overall project management and civil society sub-projects.",
},
{
role: "Consortium partner",
name: "United Efforts Consultancy (UEC)",
description: "Network strengthening, dialogue, and peer-learning.",
},
{
role: "Consortium partner",
name: "Stichting Bureau Ensemble (SBE)",
description: "Research, perception data, and knowledge publications.",
},
],
partnersHeading: "Meet Our Partners",
networkLabel: "Civil society network partners",
partnersEmpty:
"Partner organisations will be listed here as they join the SHARP-ER network.",
},
about: {
heroEyebrow: "About the project",
heroHeadline: "Strengthening the Rule of Law, Together",
lead: "SHARP-ER (Shared Action on Rule of Law Progress – Exit Route) is a nationwide initiative supporting Libya’s civil society, local institutions, and communities in advancing justice, accountability, and good governance.",
body: "Running from December 2025 to December 2027, SHARP-ER is implemented by Acted, in consortium with United Efforts Consultancy (UEC) and Stichting Bureau Ensemble (SBE), and funded by the European Union. The project works nationwide across Libya, partnering with civil society organisations, municipalities, and community leaders to strengthen access to justice, build the capacity of civil society to hold institutions accountable, and improve local governance and service delivery.",
facts: [
{ label: "Duration", value: "December 2025 – December 2027" },
{ label: "Coverage", value: "Nationwide, Libya" },
{ label: "Lead partner", value: "Acted" },
{ label: "Funded by", value: "The European Union" },
],
bodyHeading: "A nationwide project, delivered locally",
body: [
"Running from December 2025 to December 2027, SHARP-ER is implemented by Acted, in consortium with United Efforts Consultancy (UEC) and Stichting Bureau Ensemble (SBE), and funded by the European Union.",
"The project works nationwide across Libya, partnering with civil society organisations, municipalities, and community leaders to strengthen access to justice, build the capacity of civil society to hold institutions accountable, and improve local governance and service delivery.",
],
consortiumHeading: "The consortium",
consortium: [
{
name: "Acted",
role: "Lead implementing partner — overall project management and civil society sub-projects",
role: "Lead implementing partner",
description:
"Overall project management and civil society sub-projects.",
},
{
name: "United Efforts Consultancy (UEC)",
role: "Network strengthening, dialogue, and peer-learning",
role: "Consortium partner",
description: "Network strengthening, dialogue, and peer-learning.",
},
{
name: "Stichting Bureau Ensemble (SBE)",
role: "Research, perception data, and knowledge publications",
role: "Consortium partner",
description: "Research, perception data, and knowledge publications.",
},
],
pillarsHeading: "Where we focus",
@@ -192,18 +297,17 @@ const EN: PageCopy = {
"Enhancing collaboration between municipalities and local actors to improve service delivery",
},
],
legacyHeading: "Building on SHARP I and II",
legacyEyebrow: "Building on SHARP",
legacyHeading: "Carrying the SHARP I and II network into a new phase",
legacyBody:
"SHARP-ER builds on the foundation of SHARP I and II (2019–2024), carrying its network of civil society partners and its dialogue practice into a new phase focused on sustainability and exit.",
legacyLinkLabel: "Read about the SHARP legacy",
fundingNote: "Funded by the European Union.",
},
whatWeDo: {
heroHeadline: "A Participatory Approach to Justice and Governance",
lead: "We work directly with civil society, municipalities, and communities — not around them.",
workstreamsHeading: "Our areas of work",
workstreamsIntro: "SHARP-ER is organised around four connected areas of work:",
workstreamsHeading: "Four connected areas of work",
workstreams: [
{
title: "Capacity Building",
@@ -235,16 +339,21 @@ const EN: PageCopy = {
"Responsibility & Accountability",
],
principlesHeading: "How we work",
principles:
"Our approach rests on four principles: working with local actors, promoting sustainable — not one-off — solutions, connecting civil society with institutions, and supporting gradual, realistic institutional reform.",
principleTitles: [
"Working with local actors",
"Sustainable, not one-off, solutions",
"Connecting civil society with institutions",
"Gradual, realistic institutional reform",
],
activitiesCtaHeading:
"See how this work is taking shape across the network",
},
contact: {
facebookLabel: "Follow us on Facebook",
heroHeadline: "Get in Touch",
lead: "Questions about SHARP-ER, a partnership, or a specific activity? Reach out.",
formHeading: "Send us a message",
formIntro:
"Fill in the form and the SHARP-ER team will come back to you by email.",
detailsHeading: "Contact details",
generalEnquiries: "General enquiries",
websiteLabel: "Website",
@@ -274,49 +383,90 @@ const EN: PageCopy = {
const AR: PageCopy = {
home: {
/* (ar-draft) matches the English frame: the wordmark names the project. */
heroHeadline: "انطلاق المشروع رسميًا في ليبيا",
statsHeading: "SHARP-ER بالأرقام",
launchHeading: "مسيرة إطلاق مشروع SHARP-ER",
launchLead: [
"انطلقت رسميًا فعاليات مشروع SHARP-ER يوم ",
{ em: "الثلاثاء 30 يونيو" },
"، بمشاركة ممثلين عن ",
{ em: "بعثة الاتحاد الأوروبي" },
"، وعدد من ",
{ em: "الوزارات الليبية" },
"، إلى جانب ",
{ em: "شركاء المجتمع المدني المحليين" },
". وشهد الحدث إطلاق الموقع الإلكتروني الرسمي للمشروع والدعوة للتعبير عن الاهتمام (EOI).",
heroHeadline: "انطلاق مشروع SHARP-ER رسميًا في ليبيا",
heroLead:
"العمل المشترك لتعزيز سيادة القانون – مسار الخروج: مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات في ليبيا في تعزيز العدالة والمساءلة والحوكمة الرشيدة.",
heroCtaPrimary: "الدعوة للتعبير عن الاهتمام",
heroCtaSecondary: "عن المشروع",
/* Western digits, as the rest of the Arabic draft writes its numbers
("ديسمبر 2025", "SHARP I and II (2019–2024)"). */
stats: [
{ value: "3", label: "شركاء الائتلاف" },
{ value: "4", label: "مجالات العمل" },
{ value: "16", label: "مشروعًا فرعيًا للأمن الإنساني" },
{ value: "25 شهرًا", label: "ديسمبر 2025 – ديسمبر 2027" },
],
mechanismsHeading: "ما الذي أطلقه هذا الحدث",
mechanisms: [
"بناء القدرات والمهارات المحلية",
"تعزيز الشراكات بين القطاعات",
"دعم المبادرات البلدية والمبادرات المجتمعية",
launchEyebrow: "30 يونيو · فعالية الإطلاق",
launchHeading: "بداية مهمة للمشروع",
launchLead:
"جمعت فعالية الإطلاق ممثلين عن بعثة الاتحاد الأوروبي، ووزارة الشباب، ووزارة الحكم المحلي، ووزارة شؤون المرأة، ومنظمات المجتمع المدني، والشركاء، وأصحاب المصلحة من مختلف أنحاء ليبيا. وشهد الحدث افتتاح الدعوة للتعبير عن الاهتمام وإطلاق هذا الموقع الإلكتروني.",
workHeading: "ثلاثة مجالات للتغيير",
workLinkLabel: "ماذا نفعل",
newsEyebrow: "غرفة الأخبار",
newsHeading: "الأخبار والمستجدات",
newsEmpty: "ستظهر الأخبار والمستجدات هنا فور نشرها.",
faqEyebrow: "الأسئلة الشائعة",
faqHeading: "أسئلة حول المشروع",
faqLead: "شروط الأهلية والجداول الزمنية وكيفية المشاركة.",
faqEmpty: "ستُنشر إجابات الأسئلة الشائعة هنا قريبًا.",
faqCtaLabel: "المزيد في مركز المعرفة",
consortiumHeading: "الائتلاف المنفّذ",
consortium: [
{
role: "الشريك المنفّذ الرئيسي",
name: "Acted",
description: "الإدارة العامة للمشروع والمشاريع الفرعية لمنظمات المجتمع المدني.",
},
{
role: "شريك في الائتلاف",
name: "United Efforts Consultancy (UEC)",
description: "تعزيز الشبكة، والحوار، والتعلّم بين الأقران.",
},
{
role: "شريك في الائتلاف",
name: "Stichting Bureau Ensemble (SBE)",
description: "البحث، وبيانات الاستطلاع، والمنشورات المعرفية.",
},
],
partnersHeading: "تعرّف على شركائنا",
networkLabel: "شركاء شبكة المجتمع المدني",
partnersEmpty:
"ستُدرج المنظمات الشريكة هنا فور انضمامها إلى شبكة SHARP-ER.",
},
about: {
heroEyebrow: "عن المشروع",
heroHeadline: "نحو تعزيز سيادة القانون، معًا",
lead: "SHARP-ER (العمل المشترك لتعزيز سيادة القانون – مسار الخروج) هو مبادرة وطنية تدعم المجتمع المدني والمؤسسات المحلية والمجتمعات الليبية في تعزيز العدالة والمساءلة والحوكمة الرشيدة.",
body: "يمتد المشروع من ديسمبر 2025 إلى ديسمبر 2027، ويُنفَّذ من قِبل منظمة Acted بالشراكة مع United Efforts Consultancy (UEC) وStichting Bureau Ensemble (SBE)، وبتمويل من الاتحاد الأوروبي. يعمل المشروع على مستوى ليبيا بأكملها، بالتعاون مع منظمات المجتمع المدني والبلديات والقيادات المجتمعية، من أجل تحسين الوصول إلى العدالة، وبناء قدرات المجتمع المدني على مساءلة المؤسسات، وتحسين الحوكمة المحلية وتقديم الخدمات.",
/* Western digits, as the Arabic column of the approved draft writes its
own dates ("ديسمبر 2025", "(2019–2024)"). */
facts: [
{ label: "مدة المشروع", value: "ديسمبر 2025 – ديسمبر 2027" },
{ label: "نطاق العمل", value: "على مستوى ليبيا بأكملها" },
{ label: "الشريك المنفّذ الرئيسي", value: "Acted" },
{ label: "بتمويل من", value: "الاتحاد الأوروبي" },
],
bodyHeading: "مشروع وطني يُنفَّذ محليًا",
body: [
"يمتد المشروع من ديسمبر 2025 إلى ديسمبر 2027، ويُنفَّذ من قِبل منظمة Acted بالشراكة مع United Efforts Consultancy (UEC) وStichting Bureau Ensemble (SBE)، وبتمويل من الاتحاد الأوروبي.",
"يعمل المشروع على مستوى ليبيا بأكملها، بالتعاون مع منظمات المجتمع المدني والبلديات والقيادات المجتمعية، من أجل تحسين الوصول إلى العدالة، وبناء قدرات المجتمع المدني على مساءلة المؤسسات، وتحسين الحوكمة المحلية وتقديم الخدمات.",
],
consortiumHeading: "الائتلاف المنفّذ",
consortium: [
{
name: "Acted",
role: "الشريك المنفّذ الرئيسي — الإدارة العامة للمشروع والمشاريع الفرعية لمنظمات المجتمع المدني",
role: "الشريك المنفّذ الرئيسي",
description:
"الإدارة العامة للمشروع والمشاريع الفرعية لمنظمات المجتمع المدني.",
},
{
name: "United Efforts Consultancy (UEC)",
role: "تعزيز الشبكة، والحوار، والتعلّم بين الأقران",
role: "شريك في الائتلاف",
description: "تعزيز الشبكة، والحوار، والتعلّم بين الأقران.",
},
{
name: "Stichting Bureau Ensemble (SBE)",
role: "البحث، وبيانات الاستطلاع، والمنشورات المعرفية",
role: "شريك في الائتلاف",
description: "البحث، وبيانات الاستطلاع، والمنشورات المعرفية.",
},
],
pillarsHeading: "محاور تركيزنا",
@@ -337,18 +487,21 @@ const AR: PageCopy = {
"تعزيز التعاون بين البلديات والجهات الفاعلة المحلية لتحسين تقديم الخدمات",
},
],
legacyHeading: "استكمالًا لمشروعي SHARP الأول والثاني",
legacyEyebrow: "استكمالًا لمشروع SHARP",
legacyHeading:
"حمل شبكة SHARP الأول والثاني إلى مرحلة جديدة",
legacyBody:
"يبني SHARP-ER على الأساس الذي أرساه مشروعا SHARP الأول والثاني (2019–2024)، حاملًا معه شبكة الشركاء من المجتمع المدني وممارسة الحوار إلى مرحلة جديدة تركّز على الاستدامة ومسار الخروج.",
legacyLinkLabel: "اطّلع على إرث مشروع SHARP",
fundingNote: "بتمويل من الاتحاد الأوروبي.",
},
whatWeDo: {
heroHeadline: "نهج تشاركي في العدالة والحوكمة",
lead: "نعمل مباشرة مع المجتمع المدني والبلديات والمجتمعات المحلية، لا من حولها.",
workstreamsHeading: "محاور عملنا",
workstreamsIntro: "يقوم عمل SHARP-ER على أربعة محاور مترابطة:",
/* The approved Arabic column writes the same line as a sentence -
"يقوم عمل SHARP-ER على أربعة محاور مترابطة" - which the draft's frame
sets as the heading itself. */
workstreamsHeading: "أربعة محاور عمل مترابطة",
workstreams: [
{
title: "بناء القدرات",
@@ -380,15 +533,20 @@ const AR: PageCopy = {
"المساءلة والمسؤولية",
],
principlesHeading: "كيف نعمل",
principles:
"يسترشد عملنا بأربعة مبادئ: العمل مع الفاعلين المحليين، وتعزيز الحلول المستدامة لا الآنية، والربط بين المجتمع المدني والمؤسسات، ودعم إصلاح مؤسسي تدريجي وواقعي.",
principleTitles: [
"العمل مع الفاعلين المحليين",
"حلول مستدامة لا آنية",
"الربط بين المجتمع المدني والمؤسسات",
"إصلاح مؤسسي تدريجي وواقعي",
],
activitiesCtaHeading: "اطّلع على كيفية تجسّد هذا العمل عبر الشبكة",
},
contact: {
facebookLabel: "تابعونا على فيسبوك",
heroHeadline: "تواصل معنا",
lead: "لديك استفسار حول SHARP-ER، أو شراكة محتملة، أو نشاط معيّن؟ راسلنا.",
formHeading: "أرسل لنا رسالة",
formIntro: "املأ النموذج وسيعاود فريق SHARP-ER التواصل معك عبر البريد الإلكتروني.",
detailsHeading: "بيانات التواصل",
generalEnquiries: "للاستفسارات العامة",
websiteLabel: "الموقع الإلكتروني",
+63
View File
@@ -0,0 +1,63 @@
/* ============================================================================
HERO WORDMARK SUFFIX - the "-ER" after "SHARP" on the home hero
----------------------------------------------------------------------------
Not drawn in Figma: a project request, so the hero finishes on the full
name. "SHARP" is on screen from the first paint; once the page has settled,
"-ER" slides out from behind the P while it fades in.
HOW "BEHIND THE P" WORKS: the suffix sits in its own box whose inline-start
edge is exactly the P's right edge, and that box clips. The mark starts a
full box-width back - entirely inside the P's side of the clip, so nothing
overlaps "SHARP" - and travels forward to rest. The opacity reaches 1 before
the travel ends, so the glyphs read as solid for the last, slowest part of
the decelerating slide.
The translate is physical (translateX) on purpose: the lockup row is pinned
to dir="ltr" because a Latin wordmark never mirrors, so the P is always to
the left of the suffix, in Arabic too.
Pure CSS, so it runs from the server-rendered HTML without waiting for
hydration, and the `both` fill keeps the suffix hidden through the delay
instead of flashing in first. It replays whenever the hero mounts again,
e.g. on a client-side navigation back to the home page.
REDUCED MOTION: app.css shortens every animation to 0.01ms but leaves the
delay alone, which would hold the suffix hidden for the delay and then pop
it in. Here the animation is removed outright, so "SHARP-ER" is simply there
from the start.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
.sh-hero-suffix {
overflow: hidden;
}
.sh-hero-suffix__mark {
animation: sh-hero-suffix-in var(--motion-duration-reveal)
var(--motion-ease-entrance) var(--motion-delay-reveal) both;
}
@media (prefers-reduced-motion: reduce) {
.sh-hero-suffix__mark {
animation: none;
}
}
}
@keyframes sh-hero-suffix-in {
from {
opacity: 0;
transform: translateX(-100%);
}
60% {
opacity: 1;
}
to {
opacity: 1;
transform: translateX(0);
}
}
+249
View File
@@ -0,0 +1,249 @@
/* ============================================================================
HOME MOTION - the reveals the redesigned home page runs
----------------------------------------------------------------------------
Every animation in the Claude Design draft ("SHARP-ER Home v2 Dark"), carried
over as classes so the page modules stay free of inline keyframes and raw
values. Durations and easings are the motion tokens from semantic.css; the
few long, decorative loops are expressed as multiples of them rather than as
bare seconds, so retiming the system retimes the page.
TWO KINDS OF MOTION LIVE HERE
* entrance - time based, runs once from the server-rendered HTML
(.sh-rise, .sh-bar-grow), staggered with .sh-rise--2..5
* scroll - scroll-driven (`animation-timeline`), so a band reveals as
it enters the viewport and the top progress bar tracks the
document
WHY THE @supports GUARD: scroll-driven timelines ship in Chromium only. The
from-state (opacity 0) is applied ONLY where `animation-timeline: view()` is
understood, so Safari and Firefox render every band at rest, fully visible,
instead of holding an invisible element forever.
REDUCED MOTION: app.css already collapses every animation to 0.01ms. The
scroll block is additionally gated on `prefers-reduced-motion: no-preference`
so a reduced-motion visitor never gets a timeline-driven opacity ramp at all.
DIRECTION: `transform-origin` and `linear-gradient()` have no logical
keywords, so the progress bar and the brand stripe carry an explicit RTL
override. These are the only physical directions in the file, and they exist
because the property itself has no logical form - every box edge elsewhere is
still ps-/pe-/start-/end-.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
/* -------------------------------------------------------------------------
ENTRANCE - hero content, first paint
---------------------------------------------------------------------- */
.sh-rise {
animation: sh-rise var(--motion-duration-reveal) var(--motion-ease-entrance)
var(--sh-rise-delay, 0ms) both;
}
/* The hero's cascade. The step is the system's fast duration, so the whole
stagger retimes with the token instead of drifting on hard-coded ms. */
.sh-rise--2 {
--sh-rise-delay: calc(var(--motion-duration-fast) * 0.75);
}
.sh-rise--3 {
--sh-rise-delay: calc(var(--motion-duration-fast) * 1.5);
}
.sh-rise--4 {
--sh-rise-delay: calc(var(--motion-duration-fast) * 2.25);
}
.sh-rise--5 {
--sh-rise-delay: calc(var(--motion-duration-fast) * 3);
}
/* The brand stripe under the hero picture, drawn on from the inline start. */
.sh-bar-grow {
transform-origin: left center;
animation: sh-bar-grow var(--motion-duration-reveal)
var(--motion-ease-entrance) calc(var(--motion-duration-fast) * 4) both;
}
[dir="rtl"] .sh-bar-grow {
transform-origin: right center;
}
/* -------------------------------------------------------------------------
AMBIENT LOOPS - decorative, never carrying meaning
---------------------------------------------------------------------- */
/* The two soft washes behind the hero. Long, slow drifts: 22.5x and 30x the
reveal duration (18s / 24s at the shipped token). */
.sh-glow {
animation: sh-glow calc(var(--motion-duration-reveal) * 22.5) ease-in-out
infinite;
}
/* A highlight travelling across the hero picture. */
.sh-sheen {
background-size: 240% 100%;
animation: sh-sheen calc(var(--motion-duration-reveal) * 8) linear infinite;
}
/* The live dot in the hero status pill. */
.sh-pulse {
animation: sh-pulse calc(var(--motion-duration-reveal) * 2.75) ease-in-out
infinite;
}
/* -------------------------------------------------------------------------
INTERACTION
---------------------------------------------------------------------- */
/* Tiles that rise a little under the pointer. No shadow: the draft's hover
glow is deliberately left out. */
.sh-lift {
transition:
transform var(--motion-duration-base) var(--motion-ease-standard),
border-color var(--motion-duration-base) var(--motion-ease-standard),
background-color var(--motion-duration-base) var(--motion-ease-standard);
}
@media (hover: hover) {
.sh-lift:hover {
transform: translateY(-5px);
}
}
/* An FAQ answer opening under its question. */
.sh-answer {
animation: sh-answer var(--motion-duration-base) var(--motion-ease-entrance)
both;
}
/* -------------------------------------------------------------------------
SCROLL DRIVEN - Chromium today, inert and harmless elsewhere
---------------------------------------------------------------------- */
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.sh-reveal {
animation: sh-rise linear both;
animation-timeline: view();
animation-range: entry 0% entry 70%;
}
/* Cards and pictures: the same reveal with a touch of scale. */
.sh-reveal--in {
animation-name: sh-reveal-in;
animation-range: entry 0% entry 80%;
}
}
@supports (animation-timeline: scroll()) {
/* The reading-progress rule across the top of the page. It is painted
ONLY inside this @supports block: without a scroll timeline the bar
would sit there permanently at full width, which is a rule the design
never asked for. */
.sh-progress {
background-image: linear-gradient(
to right,
var(--surface-accent),
var(--text-accent) 55%,
var(--surface-danger)
);
transform-origin: left center;
animation: sh-bar-grow linear both;
animation-timeline: scroll();
}
[dir="rtl"] .sh-progress {
background-image: linear-gradient(
to left,
var(--surface-accent),
var(--text-accent) 55%,
var(--surface-danger)
);
transform-origin: right center;
}
}
}
}
@keyframes sh-rise {
from {
opacity: 0;
transform: translateY(26px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sh-reveal-in {
from {
opacity: 0;
transform: translateY(20px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sh-bar-grow {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes sh-glow {
0%,
100% {
opacity: 0.5;
transform: translate3d(-4%, 0, 0) scale(1);
}
50% {
opacity: 0.85;
transform: translate3d(6%, -3%, 0) scale(1.12);
}
}
@keyframes sh-sheen {
from {
background-position: -160% 0;
}
to {
background-position: 260% 0;
}
}
@keyframes sh-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.72);
}
}
@keyframes sh-answer {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: none;
}
}
+398 -62
View File
@@ -17,6 +17,9 @@
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(" ");
}
@@ -50,9 +53,16 @@ export interface PageSectionProps {
/** 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;
}
/**
@@ -63,6 +73,7 @@ export function PageSection({
children,
id,
surface,
band = false,
className,
bleed = false,
...rest
@@ -71,7 +82,7 @@ export function PageSection({
<section
id={id}
{...(surface ? { "data-surface": surface } : {})}
className={cx("py-3xl md:py-4xl", className)}
className={cx("py-3xl md:py-4xl", band && "sh-band", className)}
{...rest}
>
{bleed ? children : <Container>{children}</Container>}
@@ -169,8 +180,49 @@ export interface PageHeroWordmark {
/** 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 419. */
/** 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 {
@@ -195,17 +247,125 @@ export interface PageHeroProps {
lead?: string;
/**
* Brand lockup drawn above the headline. Only the home hero carries one
* (figma 253:4800, a 419x58 wordmark 48px above the headline row).
* (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
@@ -217,54 +377,158 @@ export function PageHero({
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;
const washes = ambient ?? [];
return (
<section className={cx("pt-2xl md:pt-3xl", className)}>
<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">
{wordmark ? (
/* A graphic dimension, not a spacing value - sized the way Brand.tsx
and the footer size their lockups, with the intrinsic box on the
element so the browser reserves the right height. */
<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"
/>
{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}
<div className="flex flex-col gap-lg lg:flex-row lg:items-end lg:justify-between">
<div className="flex max-w-[46rem] flex-col gap-sm">
{eyebrow ? (
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
{statusPill ? <div className={rise(2)}>{statusPill}</div> : null}
{leadWithActions ? (
<>
{/* Headline across the top ... */}
<div className={cx("flex max-w-[43.75rem] 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>
</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>
<h1 className="heading-h1 text-[var(--text-primary)] md:display-sm">
{title}
</h1>
{lead ? (
<p className="body-lg max-w-[var(--layout-prose-max)] text-[var(--text-secondary)]">
{lead}
</p>
{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>
{actions ? (
<div className="flex flex-wrap items-center gap-lg lg:justify-end">
{actions}
</div>
) : null}
</div>
)}
{media ? (
mediaTreatment === "banner" ? (
@@ -273,23 +537,54 @@ export function PageHero({
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="relative w-full overflow-hidden">
<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 object-cover md:aspect-[1244/425]"
/>
<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)]"
/>
<div className={cx("w-full", rise(5))}>
<div className="relative w-full overflow-hidden">
<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 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): three blocks flush under the
picture, 6px tall. Figma lays out 390 / 495 / 363 inside a
1244-wide clipping frame, so the last block loses 4px and the
split that is actually drawn is 390 : 495 : 359 - carried here
as flex-grow ratios so it holds at every width instead of only
at 1244. The three fills are `background/accent`,
`background/inverse` and `background/danger`, which is why the
middle block reads white on the dark hero and near-black 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)]" />
{/* The middle block is the raised dark, not the inverse:
the redesign drops pure white from the page entirely, so
the stripe reads green / dark / red on the near-black
band instead of green / white / red. */}
<span className="basis-0 grow-[495] bg-[var(--surface-raised)]" />
<span className="basis-0 grow-[359] bg-[var(--surface-danger)]" />
</div>
</div>
) : (
<img
@@ -302,6 +597,8 @@ export function PageHero({
/>
)
) : null}
{below}
</Container>
</section>
);
@@ -316,6 +613,11 @@ export interface InfoPanelProps {
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;
}
@@ -329,6 +631,7 @@ export function InfoPanel({
title,
children,
eyebrow,
eyebrowVariant = "plain",
titleAs = "h3",
className,
}: InfoPanelProps) {
@@ -337,14 +640,22 @@ export function InfoPanel({
return (
<div
className={cx(
"flex h-full flex-col gap-xs rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-lg",
"flex h-full flex-col gap-xs rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-lg",
className,
)}
>
{eyebrow ? (
<p className="label-md uppercase text-[var(--text-accent)]">{eyebrow}</p>
<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="body-lg-bold text-[var(--text-primary)]">{title}</Title>
<Title className="heading-h3 text-[var(--text-primary)]">{title}</Title>
{children ? (
<div className="body-md text-[var(--text-secondary)]">{children}</div>
) : null}
@@ -389,23 +700,48 @@ export function EmptyState({ message, title, className, children }: EmptyStatePr
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, className }: ValueChipsProps) {
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", className)}>
<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-tertiary)]">{label}</p>
<p className="label-md uppercase text-[var(--text-accent)]">{label}</p>
) : null}
<ul className="flex flex-wrap gap-sm">
<ul className={cx("flex flex-wrap gap-sm", centred && "justify-center")}>
{values.map((value) => (
<li
key={value}
className="rounded-pill border border-[var(--border-accent)] bg-[var(--surface-accent-subtle)] px-md py-2xs body-sm-bold text-[var(--text-primary)]"
/* 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>
+247
View File
@@ -0,0 +1,247 @@
/* ============================================================================
MONTH GRID - the events calendar's "Month" view
----------------------------------------------------------------------------
PURE and client-safe: no Strapi, no `~/lib/env.server`, no React. The same
helpers run in the loader (which decides which month to render) and in the
component (which draws it), which is what keeps the URL and the grid in
step.
The redesign draft ships a list/month switch for the calendar, and the
approved review note asks for exactly that ("Events as a calendar/list
toggle plus an individual event page"). The month is a URL parameter -
?view=month&month=2026-03 - so a particular month is a shareable link and
the arrows are ordinary <a>s that work with scripting off.
NOTHING HERE INVENTS A DATE. The grid is built from the entries the caller
passes in, which come from Strapi; an empty CMS produces an empty month,
never a sample event.
TIME ZONE: every calendar day is resolved in the zone the caller names
(the calendar uses Libya time), so an evening event lands on the day it is
actually held rather than on whatever day it is in the reader's browser.
The arithmetic itself is done on "YYYY-MM-DD" strings through UTC, which has
no daylight saving to fall through.
========================================================================= */
/** "YYYY-MM". */
export type CalendarMonthKey = string;
/** "YYYY-MM-DD". */
export type CalendarDayKey = string;
const MONTH_PATTERN = /^(\d{4})-(0[1-9]|1[0-2])$/;
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
/** Anything that can sit in a day cell. `href` is already localised. */
export interface CalendarEntry {
readonly id: string;
readonly title: string;
readonly href: string;
/** ISO 8601 instant, or a date-only value when `allDay`. */
readonly start: string;
readonly end?: string;
readonly allDay: boolean;
}
export interface CalendarDay {
readonly key: CalendarDayKey;
/** 1-31. Padding cells from the neighbouring months keep their own number. */
readonly dayNumber: number;
readonly inMonth: boolean;
readonly isToday: boolean;
readonly entries: readonly CalendarEntry[];
}
export interface CalendarMonth {
readonly month: CalendarMonthKey;
/** First day of the month, for formatting the month's own name. */
readonly firstDay: CalendarDayKey;
readonly previous: CalendarMonthKey;
readonly next: CalendarMonthKey;
/**
* Weekday numbers in display order, 0 = Sunday, so the component can label
* the header row with `Intl` instead of a hard-coded English list.
*/
readonly weekdays: readonly number[];
readonly days: readonly CalendarDay[];
/** How many of `entries` actually landed in this month. */
readonly entryCount: number;
}
/* ---------------------------------------------------------------------------
PARSING AND ARITHMETIC
------------------------------------------------------------------------ */
/** "2026-03" from a query parameter, or undefined when it is not one. */
export function parseMonthKey(value: string | null | undefined): CalendarMonthKey | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return MONTH_PATTERN.test(trimmed) ? trimmed : undefined;
}
/** The calendar day an instant falls on, read in `timeZone`. */
export function calendarDayIn(
value: Date | string,
timeZone: string,
): CalendarDayKey | undefined {
if (typeof value === "string" && DAY_PATTERN.test(value.trim())) {
// A CMS `date` field is already a calendar day - re-reading it in a zone
// would shift it by the offset.
return value.trim();
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return undefined;
return new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
/** The month a calendar day belongs to. */
export function monthKeyOf(day: CalendarDayKey): CalendarMonthKey {
return day.slice(0, 7);
}
function utcOf(day: CalendarDayKey): number {
return Date.parse(`${day}T00:00:00Z`);
}
function dayKeyOf(time: number): CalendarDayKey {
return new Date(time).toISOString().slice(0, 10);
}
/** "2026-03" plus `delta` months, rolling the year over. */
export function shiftMonth(
month: CalendarMonthKey,
delta: number,
): CalendarMonthKey {
const match = MONTH_PATTERN.exec(month);
if (!match) return month;
const index = Number(match[1]) * 12 + (Number(match[2]) - 1) + delta;
const year = Math.floor(index / 12);
const zeroBased = index - year * 12;
return `${String(year).padStart(4, "0")}-${String(zeroBased + 1).padStart(2, "0")}`;
}
const MS_PER_DAY = 86_400_000;
/* ---------------------------------------------------------------------------
THE GRID
------------------------------------------------------------------------ */
export interface BuildCalendarOptions {
readonly month: CalendarMonthKey;
readonly entries: readonly CalendarEntry[];
readonly timeZone: string;
/** 0 = Sunday … 6 = Saturday. The column the header row starts on. */
readonly weekStartsOn: number;
/** "Today" as an instant - passed in so the loader and the grid agree. */
readonly now: Date;
}
/**
* Six weeks of cells, always: a fixed grid never reflows as the reader pages
* through the year, and the padding cells carry their real dates so a run of
* events across a month boundary stays readable.
*/
export function buildCalendarMonth({
month,
entries,
timeZone,
weekStartsOn,
now,
}: BuildCalendarOptions): CalendarMonth {
const first = `${month}-01`;
const firstTime = utcOf(first);
const today = calendarDayIn(now, timeZone);
/* Every day each entry occupies, so a two-day workshop appears twice. */
const byDay = new Map<CalendarDayKey, CalendarEntry[]>();
for (const entry of entries) {
const from = calendarDayIn(entry.start, timeZone);
if (!from) continue;
const to = entry.end ? (calendarDayIn(entry.end, timeZone) ?? from) : from;
const start = utcOf(from);
const end = Math.max(start, utcOf(to));
// A month is 31 cells; a mis-entered end date cannot be allowed to spin.
const last = Math.min(end, start + 62 * MS_PER_DAY);
for (let time = start; time <= last; time += MS_PER_DAY) {
const key = dayKeyOf(time);
const bucket = byDay.get(key);
if (bucket) bucket.push(entry);
else byDay.set(key, [entry]);
}
}
/* Back up from the first of the month to the column the week starts on. */
const firstWeekday = new Date(firstTime).getUTCDay();
const lead = (firstWeekday - weekStartsOn + 7) % 7;
const gridStart = firstTime - lead * MS_PER_DAY;
const days: CalendarDay[] = [];
let entryCount = 0;
for (let cell = 0; cell < 42; cell += 1) {
const time = gridStart + cell * MS_PER_DAY;
const key = dayKeyOf(time);
const cellEntries = byDay.get(key) ?? [];
const inMonth = monthKeyOf(key) === month;
if (inMonth) entryCount += cellEntries.length;
days.push({
key,
dayNumber: new Date(time).getUTCDate(),
inMonth,
isToday: key === today,
entries: cellEntries,
});
}
return {
month,
firstDay: first,
previous: shiftMonth(month, -1),
next: shiftMonth(month, 1),
weekdays: Array.from({ length: 7 }, (_, index) => (weekStartsOn + index) % 7),
days,
entryCount,
};
}
/* ---------------------------------------------------------------------------
LABELS
------------------------------------------------------------------------ */
/**
* The day the reader's week starts on. Libya's week starts on Saturday, and
* an Arabic calendar that opened on Monday would read wrong; English keeps the
* Monday-first grid the draft draws.
*/
export function weekStartFor(locale: string): number {
return locale === "ar" ? 6 : 1;
}
/**
* "Mon", "Tue", … in the page's language. Built from a reference week that
* happens to start on a Sunday (2024-01-07 was one), so no weekday name is
* ever hard-coded in either language.
*/
export function weekdayLabel(
weekday: number,
intlLocale: string,
width: "short" | "narrow" | "long" = "short",
): string {
const reference = new Date(Date.UTC(2024, 0, 7 + weekday));
return new Intl.DateTimeFormat(intlLocale, {
weekday: width,
timeZone: "UTC",
}).format(reference);
}
+29
View File
@@ -478,3 +478,32 @@ export async function fetchBySlug<T>(
throw error;
}
}
/**
* How many entries match a filter, without pulling any of them back.
*
* Used by the knowledge hub, where each type card carries the number of items
* actually published under it. The figure is the CMS's own count - the
* redesign draft's "12 modules · EN / AR" was sample text and is not shipped -
* and an unreachable collection counts as nothing rather than taking the page
* down.
*/
export async function fetchCount(
resource: string,
locale: string,
filters: Record<string, unknown>,
): Promise<number> {
try {
const result = await getMany<{ readonly id?: number }>(resource, {
locale,
filters,
fields: ["id"],
pagination: { page: 1, pageSize: 1, withCount: true },
});
return result.pagination?.total ?? result.data.length;
} catch (error) {
if (!isStrapiError(error)) throw error;
warn(resource, error);
return 0;
}
}
+509 -1
View File
@@ -27,9 +27,12 @@ import { ArrowIcon, Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input";
import type { Translator } from "~/i18n";
import { Container, PageHero, type HeroWash } from "../_page-kit/sections";
import type { PaginationModel, SelectOption } from "./listing";
import { withParam } from "./listing";
import "../_page-kit/motion.css";
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
@@ -59,6 +62,57 @@ export function PageShell({
);
}
/**
* The redesigned listing hero (2026 drafts): an accent eyebrow over the
* headline, the standfirst, the two slow washes drifting behind it, the
* staggered entrance, and a rule drawn on under the whole band.
*
* It is `PageHero` from the page kit, not a second hero: the same component
* the home page uses, with `ambient` and `reveal` turned on, so every page on
* the site arrives the same way and there is one hero to maintain.
*
* `children` is the slot under the headline row - the knowledge hub puts its
* search box there; `actions` is the page kit's own inline-end slot.
*/
export function ListingHero({
heading,
intro,
eyebrow,
actions,
ambient,
children,
}: {
heading: string;
intro?: string;
eyebrow?: string;
actions?: ReactNode;
/** This page's own drifting wash - every draft positions its own. */
ambient?: readonly HeroWash[];
children?: ReactNode;
}) {
return (
<div className="flex flex-col gap-2xl">
<PageHero
title={heading}
{...(eyebrow ? { eyebrow } : {})}
{...(intro ? { lead: intro } : {})}
{...(actions ? { actions } : {})}
{...(ambient ? { ambient } : {})}
reveal
/>
{children ? <Container>{children}</Container> : null}
{/* The draft closes the hero with a hairline. Drawn on from the inline
start rather than simply appearing - the `barGrow` of the drafts. */}
<div
aria-hidden="true"
className="sh-bar-grow h-px w-full bg-[var(--border-subtle)]"
/>
</div>
);
}
export function PageHeader({
heading,
intro,
@@ -261,8 +315,11 @@ export function EmptyState({
return (
<div
className={cx(
// The drafts draw the empty panel as a dashed outline on whatever band
// it sits on - no fill of its own, so it is correct on the #0B0B0A
// ground and inside a #1D1D1B band alike.
"flex flex-col items-start gap-md rounded-card border border-dashed",
"border-border-default bg-surface-sunken p-2xl",
"border-border-default p-2xl",
)}
>
<p className="heading-h3 text-text-primary">{title}</p>
@@ -524,3 +581,454 @@ export function MetaLine({ items }: { items: readonly string[] }) {
</p>
);
}
/* ---------------------------------------------------------------------------
PILL FILTERS
----------------------------------------------------------------------------
The 2026 drafts replace the underlined tab strip with a row of pills on the
calendar, the calls list and the activities index: a dot marks the pill in
force and the rest are outlined. They are LINKS, exactly as the tab strip
was - each combination of filters is its own URL, so it survives a share, a
bookmark, the back button and a crawler that only follows links.
------------------------------------------------------------------------ */
export interface FilterPill {
readonly id: string;
readonly label: string;
readonly href: string;
readonly isCurrent: boolean;
}
export function FilterPills({
label,
pills,
shape = "pill",
hideLabel = false,
children,
}: {
label: string;
pills: readonly FilterPill[];
/**
* The drafts draw two shapes on the same row: the filters themselves are
* fully rounded, while the calendar's List / Month switch is a pair of
* square controls. Same markup, same behaviour, one radius token apart.
*/
shape?: "pill" | "control";
/** The view switch carries no visible caption in the draft. */
hideLabel?: boolean;
/** Anything that belongs on the same row - a region select, a view switch. */
children?: ReactNode;
}) {
const radius = shape === "pill" ? "rounded-pill" : "rounded-[var(--radius-control)]";
return (
<nav aria-label={label} className="flex flex-wrap items-center gap-sm">
<p
className={cx(
"label-md uppercase text-text-tertiary",
hideLabel && "sr-only",
)}
>
{label}
</p>
<ul className="flex flex-wrap items-center gap-xs">
{pills.map((pill) => (
<li key={pill.id}>
<Link
to={pill.href}
prefetch="intent"
aria-current={pill.isCurrent ? "page" : undefined}
className={cx(
"inline-flex items-center gap-xs border px-md py-xs",
radius,
"body-sm-bold transition-colors",
"duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]",
pill.isCurrent
? "border-border-accent bg-surface-accent-subtle text-text-primary"
: "border-border-default text-text-secondary hover:border-border-accent hover:text-text-primary",
)}
>
{/* Decorative: `aria-current` already says which pill is on. */}
{pill.isCurrent ? (
<span
aria-hidden="true"
className="size-[7px] shrink-0 rounded-pill bg-[var(--surface-accent)]"
/>
) : null}
{pill.label}
</Link>
</li>
))}
</ul>
{children}
</nav>
);
}
/**
* A single `<select>` that navigates on change, styled as one of the pills
* beside it. A real `<form method="get">`, so it still applies with scripting
* off; `keep` carries the filters that are not this one, because a GET submit
* replaces the whole query string.
*/
export function PillSelect({
name,
label,
value,
options,
allLabel,
applyLabel,
action,
keep,
}: {
name: string;
label: string;
value: string;
options: readonly SelectOption[];
allLabel: string;
/** Label of the fallback submit - shown because a <select> cannot submit. */
applyLabel: string;
action: string;
/** Other query parameters to preserve, as [name, value] pairs. */
keep?: readonly (readonly [string, string])[];
}) {
const submit = useSubmit();
return (
<Form
method="get"
action={action}
aria-label={label}
onChange={(event) => {
if (event.target instanceof HTMLSelectElement) {
submit(event.currentTarget, { method: "get" });
}
}}
className="flex items-center gap-xs"
>
{(keep ?? []).map(([key, kept]) => (
<input key={key} type="hidden" name={key} value={kept} />
))}
<label className="sr-only" htmlFor={`pill-${name}`}>
{label}
</label>
<select
id={`pill-${name}`}
name={name}
defaultValue={value}
className={cx(
"body-sm-bold rounded-pill border border-border-default bg-surface-raised",
"px-md py-xs text-text-secondary transition-colors",
"duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]",
"hover:border-border-accent hover:text-text-primary",
)}
>
<option value="">{allLabel}</option>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{/* Kept for the case where scripting is off - the same bargain the
filter bar strikes. A <select> alone cannot submit its form. */}
<Button type="submit" variant="ghost" size="sm">
{applyLabel}
</Button>
</Form>
);
}
/* ---------------------------------------------------------------------------
FACET SIDEBAR - the resources listing
----------------------------------------------------------------------------
The draft draws the resources filters as checkbox groups in a sticky rail.
They are rendered as LINKS with a checkbox-shaped marker: one click sets the
facet, clicking the active option clears it, and the result is still one
URL per view. A real <input type="checkbox"> would promise multi-select,
which the Strapi query behind this listing does not offer.
------------------------------------------------------------------------ */
export interface FacetOption {
readonly value: string;
readonly label: string;
readonly href: string;
readonly isCurrent: boolean;
}
export function FacetGroup({
heading,
options,
}: {
heading: string;
options: readonly FacetOption[];
}) {
if (options.length === 0) return null;
return (
<div className="flex flex-col gap-sm border-t border-border-subtle pt-md">
<p className="label-md uppercase text-text-tertiary">{heading}</p>
<ul className="flex flex-col gap-2xs">
{options.map((option) => (
<li key={option.value}>
<Link
to={option.href}
prefetch="intent"
aria-current={option.isCurrent ? "true" : undefined}
className={cx(
"flex items-start gap-sm py-2xs text-start body-sm-bold",
"transition-colors duration-[var(--motion-duration-fast)]",
"ease-[var(--motion-ease-standard)]",
option.isCurrent
? "text-text-primary"
: "text-text-secondary hover:text-text-primary",
)}
>
<span
aria-hidden="true"
className={cx(
"mt-2xs flex size-[0.875rem] shrink-0 items-center justify-center",
"rounded-[var(--radius-control)] border",
option.isCurrent
? "border-border-accent"
: "border-border-default",
)}
>
{option.isCurrent ? (
<span className="size-[7px] bg-[var(--surface-accent)]" />
) : null}
</span>
<span>{option.label}</span>
</Link>
</li>
))}
</ul>
</div>
);
}
/** The rail the facet groups sit in - sticky from the large breakpoint up. */
export function FacetRail({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<aside
aria-label={label}
className={cx(
"flex min-w-0 flex-col gap-lg lg:sticky lg:top-[var(--layout-sticky-top)]",
"lg:max-w-[20rem] lg:self-start",
)}
>
{children}
</aside>
);
}
/* ---------------------------------------------------------------------------
SEARCH
------------------------------------------------------------------------ */
/**
* A standalone search box - the hub's hero field and the resources rail both
* use it. `keep` preserves the filters that are not the query, so searching
* inside a filtered view does not silently clear the filters.
*/
export function SearchForm({
action,
label,
placeholder,
defaultValue,
submitLabel,
keep,
name = "q",
className,
}: {
action: string;
label: string;
placeholder: string;
defaultValue: string;
submitLabel: string;
keep?: readonly (readonly [string, string])[];
name?: string;
className?: string;
}) {
return (
<Form
method="get"
action={action}
role="search"
aria-label={label}
className={cx("flex flex-wrap items-end gap-sm", className)}
>
{(keep ?? []).map(([key, value]) => (
<input key={key} type="hidden" name={key} value={value} />
))}
<Input
type="search"
name={name}
label={label}
placeholder={placeholder}
defaultValue={defaultValue}
className="min-w-0 flex-1"
/>
<Button type="submit" variant="primary" size="md">
{submitLabel}
</Button>
</Form>
);
}
/* ---------------------------------------------------------------------------
CALLOUT BAND
----------------------------------------------------------------------------
The wide panel the drafts close a page with: a raised surface, an accent
rule down the inline-start edge, a headline and one action. Used by the
newsroom ("Are you a partner organisation?") and by the knowledge hub's
repository note.
It is NOT Card.CTA: that card is a filled green field for a primary action,
and the drafts draw this one as a quiet panel on the page surface.
------------------------------------------------------------------------ */
export function CalloutBand({
eyebrow,
headline,
body,
action,
headlineAs = "h2",
}: {
headline: string;
action: { readonly to: string; readonly label: string };
eyebrow?: string;
body?: string;
headlineAs?: "h2" | "h3";
}) {
const Headline = headlineAs;
return (
<section
className={cx(
"sh-reveal flex flex-col gap-lg rounded-card border border-border-subtle",
"border-s-4 border-s-[var(--border-accent)] bg-surface-raised p-xl",
"md:flex-row md:items-center md:justify-between",
)}
>
<div className="flex max-w-[var(--layout-prose-max)] flex-col gap-xs">
{eyebrow ? (
<p className="label-md uppercase text-text-accent">{eyebrow}</p>
) : null}
<Headline className="heading-h3 text-text-primary">{headline}</Headline>
{body ? <p className="body-md text-text-secondary">{body}</p> : null}
</div>
<Button
as={Link}
to={action.to}
variant="primary"
size="md"
iconEnd={<ArrowIcon />}
className="shrink-0"
>
{action.label}
</Button>
</section>
);
}
/* ---------------------------------------------------------------------------
FORM CONTROLS
----------------------------------------------------------------------------
`FilterSelect` above is a GET filter: it has no error state and its id is
namespaced for the filter bar. A select that is part of a POSTed form needs
both, so it gets its own control here rather than a second set of props on
the filter one.
It is native on purpose, for the same reasons `FilterSelect` is: correct in
every browser with a keyboard and a screen reader, and usable before - and
without - hydration. The visual treatment is the field treatment from
~/components/ui/Input, spent through the same tier-2 tokens.
------------------------------------------------------------------------ */
export function FormSelect({
id,
name,
label,
hint,
error,
defaultValue,
options,
required = false,
className,
}: {
id: string;
name: string;
label: string;
options: readonly SelectOption[];
hint?: string;
error?: string;
defaultValue?: string;
required?: boolean;
className?: string;
}) {
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const describedBy =
[hint ? hintId : null, error ? errorId : null].filter(Boolean).join(" ") ||
undefined;
return (
<div className={cx("sh-field flex min-w-0 flex-col gap-xs", className)}>
<label className="sh-field__label label-md" htmlFor={id}>
{label}
{required ? (
<span className="sh-field__required ms-2xs" aria-hidden="true">
*
</span>
) : null}
</label>
<select
id={id}
name={name}
defaultValue={defaultValue}
required={required}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className={cx(
"body-md w-full rounded-[var(--radius-control)] border bg-field-bg",
"border-field-border px-md py-sm text-[var(--field-fg)]",
"transition-colors duration-[var(--motion-duration-fast)]",
"ease-[var(--motion-ease-standard)] hover:border-[var(--field-border-hover)]",
)}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{hint ? (
<p id={hintId} className="sh-field__hint body-sm">
{hint}
</p>
) : null}
{error ? (
<p id={errorId} role="alert" className="sh-field__error body-sm">
{error}
</p>
) : null}
</div>
);
}
+194
View File
@@ -227,3 +227,197 @@ export const NEWS_DETAIL_COPY: Localised<Pick<DetailCopy, "backToListing" | "abo
aboutThis: "عن هذا المستجد",
},
};
/* ---------------------------------------------------------------------------
LISTING CHROME - the redesign's page furniture
----------------------------------------------------------------------------
The strings the six listing pages gained with the 2026 redesign drafts:
hero eyebrows, the pill and facet labels, the calendar's view switch, the
knowledge hub's FAQ band and its repository callout.
EVERYTHING HERE IS FURNITURE, NOT CONTENT. Not one figure, date, name or
commitment from the drafts is carried over: the drafts' sample entries,
their "12 modules", their "Closes 15 August 2026", their "within four
weeks" and their TBD counters are all absent by design (CLAUDE.md rule 5).
The two places where a draft's sentence IS reproduced are traceable to an
approved source and noted inline.
`ListingChrome` is derived from the English object, so a key added on one
side and forgotten on the other is a compile error, not a blank label.
------------------------------------------------------------------------ */
const LISTING_CHROME_EN = {
news: {
/** Draft eyebrow over the §5 headline. */
eyebrow: "Newsroom",
/** The draft's lead card reads "Read the full update". */
readFull: "Read the full update",
/** Visually hidden heading over the lead article. */
featuredLabel: "Latest update",
/** Visually hidden heading over the rest of the grid. */
moreLabel: "More updates",
},
events: {
eyebrow: "Events calendar",
/** Label of the list / month switch. */
viewLabel: "View",
viewList: "List",
viewMonth: "Month",
/** Accessible name of the month grid. */
calendarLabel: "Events by month",
previousMonth: "Previous month",
nextMonth: "Next month",
monthEmpty: "No events are scheduled in this month.",
/** Announced before the day number of a cell that carries events. */
dayLabel: "Events on {date}",
},
applications: {
eyebrow: "Applications and opportunities",
/**
* The draft's three-step application form IS built (see
* ../applications-and-opportunities.tsx), but its attachment limit, its
* reference number and its "within four weeks" are not: none of the three
* is true of this project, so the page states none of them. The wording
* that replaced them lives in ../_events-opportunities/copy.ts under
* `apply`, annotated key by key.
*/
pathwayAllLabel: "Ways to take part",
},
resources: {
eyebrow: "Resources and publications",
/** Accessible name of the filter sidebar. */
facetsLabel: "Refine these results",
searchPlaceholder: "Title, author, keyword",
/** `resource.resourceLanguage`, spelled out for the card's language chip. */
languageEn: "English",
languageAr: "Arabic",
languageBilingual: "English and Arabic",
},
knowledge: {
eyebrow: "Knowledge hub",
searchPlaceholder: "Search the hub — topic, module, or keyword",
/**
* The count under a type card. The number always comes from Strapi, and
* the line is dropped entirely at zero rather than reading "0 published".
*/
itemCount: "{count} published",
browseLabel: "Browse by type",
faqEyebrow: "Plain-language basics",
faqHeading: "Frequently asked questions",
/** Expanded from the approved §7 line for the FAQ entry of the hub. */
faqIntro:
"Plain-language answers on rule of law, governance, and civic participation basics.",
faqEmpty: "Answers are being prepared and will appear here.",
repositoryEyebrow: "Long-term repository",
/**
* ToR §2 and §3.3: the site "acts as a long-term repository for knowledge
* and resource sharing linked to SHARP-ER activities and outputs" "with
* version control, DOI/handle readiness, and searchable metadata". The
* draft's stronger claim - that every item already is versioned and kept
* beyond the project's end - is not made here.
*/
repositoryHeadline:
"A long-term repository for knowledge and resources linked to SHARP-ER activities and outputs, with version control and searchable metadata.",
repositoryAction: "Browse publications",
},
activities: {
partnerFilterLabel: "Filter by partner",
allPartners: "All partners",
/** Visually hidden heading over the two onward cards. */
nextStepsLabel: "Where to go next",
nextEventsEyebrow: "Next",
nextApplicationsEyebrow: "Get involved",
},
} as const;
export type ListingChrome = {
readonly [Section in keyof typeof LISTING_CHROME_EN]: {
readonly [Key in keyof (typeof LISTING_CHROME_EN)[Section]]: string;
};
};
const LISTING_CHROME_AR: ListingChrome = {
news: {
eyebrow: "غرفة الأخبار",
readFull: "اقرأ المستجد كاملًا",
featuredLabel: "أحدث مستجد",
moreLabel: "مستجدات أخرى",
},
events: {
eyebrow: "التقويم الزمني للفعاليات",
viewLabel: "طريقة العرض",
viewList: "قائمة",
viewMonth: "شهر",
calendarLabel: "الفعاليات حسب الشهر",
previousMonth: "الشهر السابق",
nextMonth: "الشهر التالي",
monthEmpty: "لا توجد فعاليات مجدولة في هذا الشهر.",
dayLabel: "فعاليات يوم {date}",
},
applications: {
eyebrow: "طلبات وفرص المشاركة",
pathwayAllLabel: "طرق المشاركة",
},
resources: {
eyebrow: "الموارد والمنشورات",
facetsLabel: "تصفية النتائج",
searchPlaceholder: "العنوان أو الكاتب أو كلمة مفتاحية",
languageEn: "الإنجليزية",
languageAr: "العربية",
languageBilingual: "الإنجليزية والعربية",
},
knowledge: {
eyebrow: "مركز المعرفة",
searchPlaceholder: "ابحث في المركز: موضوع أو وحدة أو كلمة مفتاحية",
itemCount: "عدد المواد: {count}",
browseLabel: "التصفح حسب النوع",
faqEyebrow: "أساسيات بلغة مبسّطة",
faqHeading: "الأسئلة الشائعة",
faqIntro:
"إجابات بلغة مبسّطة حول أساسيات سيادة القانون والحوكمة والمشاركة المدنية.",
faqEmpty: "يجري إعداد الإجابات وستظهر هنا.",
repositoryEyebrow: "مستودع طويل الأمد",
repositoryHeadline:
"مستودع طويل الأمد للمعرفة والموارد المرتبطة بأنشطة SHARP-ER ومخرجاته، مع ضبط الإصدارات وبيانات وصفية قابلة للبحث.",
repositoryAction: "تصفّح المنشورات",
},
activities: {
partnerFilterLabel: "التصفية حسب الشريك",
allPartners: "جميع الشركاء",
nextStepsLabel: "إلى أين بعد ذلك",
nextEventsEyebrow: "التالي",
nextApplicationsEyebrow: "شارك معنا",
},
};
const LISTING_CHROME: Localised<ListingChrome> = {
en: LISTING_CHROME_EN,
ar: LISTING_CHROME_AR,
};
/** The listing chrome for a locale. Never returns "". */
export function getListingChrome(locale: Locale): ListingChrome {
return LISTING_CHROME[locale] ?? LISTING_CHROME_EN;
}
/**
* `resource.resourceLanguage` spelled out. Returns undefined for a value the
* enumeration does not carry, so the chip is dropped rather than invented.
*/
export function resourceLanguageLabel(
value: string | null | undefined,
locale: Locale,
): string | undefined {
const copy = getListingChrome(locale).resources;
switch (value) {
case "en":
return copy.languageEn;
case "ar":
return copy.languageAr;
case "bilingual":
return copy.languageBilingual;
default:
return undefined;
}
}
+230 -90
View File
@@ -1,18 +1,43 @@
/* ============================================================================
ABOUT - /en/about and /ar/about
----------------------------------------------------------------------------
Section 1 of docs/SHARPER_Website_Draft_Section_EN_AR.docx: who SHARP-ER is,
the three consortium members and their roles, the three focus areas, and the
SHARP I / II legacy the project builds on.
The redesigned About page ("About", Claude Design draft), carried onto the
project's own design system. Five bands, alternating the two dark surfaces
the way the draft alternates #0B0B0A and #1D1D1B:
The draft copy is the fallback; every block here is overridden field by
field by api::about-page.about-page as soon as an editor fills it in.
Hero eyebrow, headline and lead on the sunken surface, with the
two drifting washes behind it.
Facts a four-cell seam grid: the run, the coverage, the lead
partner and the funder.
Nationwide the approved body prose beside the three focus areas.
Consortium the three members as role / name / deliverable panels, under
a header row that carries the EU funding line.
Legacy the SHARP I and II panel with the accent start-edge, and the
call to action into What We Do.
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
--------------------------------------------
* Nothing was cut for sourcing: every fact, role and sentence the draft
prints is in section 1 of docs/SHARPER_Website_Draft_Section_EN_AR.docx or
in the project brief. What the draft does NOT print - a figure of any kind
- stays absent, because the draft marks every statistic "TBD".
* The draft's coloured hover glow (a green box-shadow under a hovered card)
is deliberately dropped; the tiles only rise.
Every block is the FALLBACK: api::about-page.about-page overrides it field
by field as soon as an editor fills it in.
MOTION: classes from _page-kit/motion.css - the staggered hero entrance, the
drifting washes, the scroll-driven band reveals and the hover lift.
There is no Arabic frame in the draft, so /ar is this same DOM under
`dir="rtl"`: every inline edge below is logical (ps-/pe-/start-/end-, or an
explicit `border-inline-start-*`), never left/right.
========================================================================= */
import { Link } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { createTranslator, sectionPath } from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
@@ -29,10 +54,11 @@ import {
InfoPanel,
PageHero,
PageSection,
Prose,
SectionHeader,
} from "./_page-kit/sections";
import "./_page-kit/motion.css";
export async function loader({ params }: Route.LoaderArgs) {
const locale = routeLocale(params);
@@ -69,7 +95,10 @@ export async function loader({ params }: Route.LoaderArgs) {
.map((member) => ({
id: member.partner?.documentId ?? String(member.id ?? member.partner?.id),
name: member.partner?.name ?? "",
role: member.roleDescription ?? member.partner?.description ?? "",
/* The chip and the line under the name are separate fields, so a
CMS-authored member reads exactly like the bundled copy. */
role: member.role ?? null,
description: member.roleDescription ?? member.partner?.description ?? "",
website: member.partner?.website ?? null,
})),
legacy: {
@@ -115,76 +144,179 @@ export function meta({ loaderData, matches }: Route.MetaArgs) {
});
}
/**
* The accent start-edge the draft paints down the left of its closing panel.
* Written as arbitrary properties rather than `border-s-4`: the edge needs its
* own colour as well as its own width, and `border-inline-start-*` is the only
* spelling that mirrors in Arabic without an `rtl:` override.
*/
const ACCENT_EDGE =
"[border-inline-start-color:var(--border-accent)] [border-inline-start-width:4px]";
/** One member of the consortium, however it reached the page. */
interface ConsortiumMember {
id: string;
name: string;
/** The role chip - `consortium.role` in the CMS, or the bundled copy's. */
role: string | null;
description: string;
website: string | null;
}
export default function About({ loaderData }: Route.ComponentProps) {
const { locale, hero, body, richTextBlocks, legacy } = loaderData;
const t = createTranslator(locale);
const copy = pageCopy(locale).about;
const whatWeDoCopy = pageCopy(locale).whatWeDo;
const pillars = loaderData.pillars.length > 0 ? loaderData.pillars : copy.pillars;
const consortium =
const consortium: readonly ConsortiumMember[] =
loaderData.consortium.length > 0
? loaderData.consortium
? loaderData.consortium.map((member) => ({
id: member.id,
name: member.name,
role: member.role,
description: member.description,
website: member.website,
}))
: copy.consortium.map((member) => ({
id: member.name,
name: member.name,
role: member.role,
description: member.description,
website: null,
}));
return (
<>
<PageHero
className="border-b border-[var(--border-subtle)] bg-[var(--surface-sunken)] [--layout-hero-bottom:clamp(3rem,6vw,4.5rem)]"
title={hero.headline ?? copy.heroHeadline}
eyebrow={hero.eyebrow ?? t("nav.sections.aboutAndStrategy")}
eyebrow={hero.eyebrow ?? copy.heroEyebrow}
lead={hero.subheading ?? copy.lead}
ambient={[{ top: -260, from: "start", offset: "6%", width: 760, height: 600, durationSeconds: 20 }]}
reveal
{...(hero.media ? { media: hero.media } : {})}
/>
<PageSection className="pt-0 md:pt-0">
<Prose>
{body ? (
<RichText markdown={body} />
) : (
<p className="body-md text-[var(--text-secondary)]">{copy.body}</p>
)}
{richTextBlocks.map((block) => (
<RichText key={block.id} markdown={block.body} />
{/* THE FACTS BAND - four cells divided by a one-pixel seam, which is the
band's own background showing between them. Facts, not figures: the
run, the coverage, the lead partner and the funder, each written out
in the approved draft. */}
<PageSection className="border-b border-[var(--border-subtle)] bg-[var(--surface-sunken)] pt-0 pb-0 md:pt-0 md:pb-0">
<dl className="grid grid-cols-1 gap-[1px] bg-[var(--border-subtle)] sm:grid-cols-2 lg:grid-cols-4">
{copy.facts.map((fact) => (
<div
key={fact.label}
className="sh-reveal flex flex-col gap-2xs bg-[var(--surface-sunken)] p-lg transition-colors duration-[var(--motion-duration-base)] hover:bg-[var(--surface-raised)]"
>
<dt className="label-md uppercase text-[var(--text-tertiary)]">
{fact.label}
</dt>
<dd className="body-lg-bold text-[var(--text-primary)]">
{fact.value}
</dd>
</div>
))}
<p className="body-sm-bold text-[var(--text-accent)]">{copy.fundingNote}</p>
</Prose>
</dl>
</PageSection>
{/* WHERE WE FOCUS */}
<PageSection aria-labelledby="about-pillars-heading">
<div className="flex flex-col gap-2xl">
<SectionHeader id="about-pillars-heading" title={copy.pillarsHeading} />
{/* A NATIONWIDE PROJECT, DELIVERED LOCALLY - the approved body prose on
the inline-start side, the three focus areas stacked beside it. The
band is the raised dark the draft paints (#1d1d1b) and has to say so:
the document ground is now the sunken near-black. */}
<PageSection
aria-labelledby="about-body-heading"
band
>
<div className="grid grid-cols-1 items-start gap-2xl lg:grid-cols-2 lg:gap-4xl">
<div className="sh-reveal flex flex-col gap-md">
<SectionHeader id="about-body-heading" title={copy.bodyHeading} />
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
{pillars.map((pillar) => (
<li key={pillar.title}>
<InfoPanel title={pillar.title}>{pillar.description}</InfoPanel>
</li>
{body ? (
<RichText markdown={body} />
) : (
/* Two paragraphs, as the draft sets them - the run and the
consortium first, then what the project does. */
copy.body.map((paragraph) => (
<p
key={paragraph}
className="body-md text-[var(--text-secondary)]"
>
{paragraph}
</p>
))
)}
{richTextBlocks.map((block) => (
<RichText key={block.id} markdown={block.body} />
))}
</ul>
</div>
<div className="flex flex-col gap-md">
{/* The draft prints no heading over this column; the heading exists
for the document outline and for screen readers. */}
<h3 id="about-pillars-heading" className="sr-only">
{copy.pillarsHeading}
</h3>
<ul
aria-labelledby="about-pillars-heading"
className="flex list-none flex-col gap-md"
>
{pillars.map((pillar) => (
<li key={pillar.title}>
<InfoPanel
className="sh-lift sh-reveal sh-reveal--in hover:border-[var(--border-accent)]"
title={pillar.title}
titleAs="h4"
>
{pillar.description}
</InfoPanel>
</li>
))}
</ul>
</div>
</div>
</PageSection>
{/* THE CONSORTIUM */}
<PageSection aria-labelledby="about-consortium-heading">
{/* The draft draws no rule between the raised band above and this one -
the change of surface is the seam - so this band carries none. */}
<PageSection
aria-labelledby="about-consortium-heading"
className="bg-[var(--surface-sunken)]"
>
<div className="flex flex-col gap-2xl">
<SectionHeader
id="about-consortium-heading"
title={copy.consortiumHeading}
/>
<div className="sh-reveal flex flex-wrap items-end justify-between gap-lg border-b border-[var(--border-default)] pb-lg">
<SectionHeader
id="about-consortium-heading"
title={copy.consortiumHeading}
/>
{/* EU visibility, as the draft draws it beside this heading. The
emblem itself is carried by the navigation and the footer. */}
<p className="label-md uppercase text-[var(--text-tertiary)]">
{t("site.euStatement")}
</p>
</div>
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
{consortium.map((member) => (
<li key={member.id}>
<InfoPanel title={member.name}>
<span className="block">{member.role}</span>
<InfoPanel
/* On the sunken band the draft lifts these cards to the
raised dark it uses for a page band (#1d1d1b), one step
lighter than the panel shade InfoPanel defaults to. The
`!` is what makes the override beat the component's own
`bg-*` utility, which sorts after this one. */
className="sh-lift sh-reveal sh-reveal--in hover:border-[var(--border-accent)]"
title={member.name}
{...(member.role
? { eyebrow: member.role, eyebrowVariant: "chip" as const }
: {})}
>
<span className="block">{member.description}</span>
{member.website ? (
<a
href={member.website}
@@ -202,15 +334,21 @@ export default function About({ loaderData }: Route.ComponentProps) {
</div>
</PageSection>
{/* SHARP I AND II */}
<PageSection aria-labelledby="about-legacy-heading">
<div className="flex flex-col gap-lg">
<SectionHeader
id="about-legacy-heading"
title={legacy.headline ?? copy.legacyHeading}
/>
{/* SHARP I AND II, AND THE WAY ON TO WHAT WE DO */}
<PageSection
aria-labelledby="about-legacy-heading"
className="bg-[var(--surface-sunken)] pt-0 md:pt-0"
>
<div
className={`sh-reveal flex flex-wrap items-center justify-between gap-xl rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-lg md:p-xl ${ACCENT_EDGE}`}
>
<div className="flex max-w-[44rem] flex-col gap-sm">
<SectionHeader
id="about-legacy-heading"
eyebrow={copy.legacyEyebrow}
title={legacy.headline ?? copy.legacyHeading}
/>
<Prose>
{legacy.body ? (
<RichText markdown={legacy.body} />
) : (
@@ -218,49 +356,51 @@ export default function About({ loaderData }: Route.ComponentProps) {
{copy.legacyBody}
</p>
)}
</Prose>
</div>
{/* The draft marks the legacy page as "[link: …]" with no target yet,
so the button appears only once the CMS supplies a real one. */}
{legacy.link ? (
legacy.link.isExternal ? (
<Button
as="a"
href={legacy.link.href}
target="_blank"
rel="noopener noreferrer"
variant="secondary"
iconEnd={<ArrowIcon />}
className="self-start"
>
{legacy.link.label}
</Button>
) : (
<Button
as={Link}
to={legacy.link.href}
prefetch="intent"
variant="secondary"
iconEnd={<ArrowIcon />}
className="self-start"
>
{legacy.link.label}
</Button>
)
) : null}
<div className="flex flex-wrap items-center gap-md">
<Button
as={Link}
to={sectionPath("whatWeDo", locale)}
prefetch="intent"
size="lg"
iconEnd={<ArrowIcon />}
>
{t("actions.learnWhatWeDo")}
</Button>
{/* The draft marks the legacy page as "[link: …]" with no target
yet, so this second button appears only once the CMS gives it
a real one. */}
{legacy.link ? (
legacy.link.isExternal ? (
<Button
as="a"
href={legacy.link.href}
target="_blank"
rel="noopener noreferrer"
variant="secondary"
size="lg"
iconEnd={<ArrowIcon />}
>
{legacy.link.label}
</Button>
) : (
<Button
as={Link}
to={legacy.link.href}
prefetch="intent"
variant="secondary"
size="lg"
iconEnd={<ArrowIcon />}
>
{legacy.link.label}
</Button>
)
) : null}
</div>
</div>
</PageSection>
<PageSection>
<Card.CTA
headline={whatWeDoCopy.heroHeadline}
body={whatWeDoCopy.lead}
action={{
href: sectionPath("whatWeDo", locale),
label: t("actions.learnWhatWeDo"),
}}
/>
</PageSection>
</>
);
}
+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>
);
}
File diff suppressed because it is too large Load Diff
+202 -111
View File
@@ -13,18 +13,40 @@
Every message the reader can see is localised: field labels and the generic
errors come from the i18n catalogue, the page prose from the approved draft.
THE REDESIGN
------------
The Claude Design draft ("Contact") lays the page out as two columns on the
sunken surface: the form in a raised panel, and beside it the channels, the
consortium disclosure list and the EU funding note. Motion is the classes in
_page-kit/motion.css - the hero entrance and washes, the band reveals, the
panel that rises when the message is sent, and the answer that opens under a
question. The draft's coloured hover glow is deliberately dropped.
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
* "The project team replies within five working days." No approved
document makes that commitment, so it is not published (CLAUDE.md 5).
* "info@sharper.ly" and "Facebook - SHARP-ER Libya". The approved draft
writes both as placeholders ("[project email - e.g. ...]", "[Facebook
page link placeholder]"), so the address and the social links come from
the CMS and the page says plainly that they are still to be published.
* The three consortium addresses, which the draft fills with "Contact
details TBD" - the disclosure list renders only what the CMS holds.
========================================================================= */
import { useEffect, useRef } from "react";
import { useEffect, useRef, type ReactNode } from "react";
import { SITE_FACEBOOK_URL } from "~/lib/site-links";
import { data, Form, Link, useLocation, useNavigation } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { EuBadge } from "~/components/ui/EuBadge";
import { Input, Textarea } from "~/components/ui/Input";
import { createTranslator, sectionPath, type Locale } from "~/i18n";
import { getServerEnv } from "~/lib/env.server";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/contact";
import { Accordion, type AccordionItem } from "./_page-kit/accordion";
import type { CmsContactPage } from "./_page-kit/cms-types";
import { list, loadSingle, toImage } from "./_page-kit/cms.server";
import { pageCopy } from "./_page-kit/copy";
@@ -32,13 +54,13 @@ import { routeLocale } from "./_page-kit/locale";
import { RichText } from "./_page-kit/rich-text";
import {
EmptyState,
InfoPanel,
PageHero,
PageSection,
Prose,
SectionHeader,
} from "./_page-kit/sections";
import "./_page-kit/motion.css";
/* ---------------------------------------------------------------------------
LOADER
------------------------------------------------------------------------ */
@@ -283,6 +305,51 @@ export async function action({ params, request }: Route.ActionArgs) {
PAGE
------------------------------------------------------------------------ */
/** See the note on the same constant in about.tsx. */
const ACCENT_EDGE =
"[border-inline-start-color:var(--border-accent)] [border-inline-start-width:4px]";
/** The tick the draft draws in a filled circle over the "message sent" panel. */
function SentMark() {
return (
<span
aria-hidden="true"
className="flex size-12 shrink-0 items-center justify-center rounded-pill bg-[var(--surface-accent)] text-[var(--text-on-accent)]"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M5 13l4.5 4.5L19 7"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
);
}
/** One label-and-value row of the channels list. */
function Channel({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<div className="flex flex-col gap-2xs border-b border-[var(--border-subtle)] pb-md">
<dt className="label-md uppercase text-[var(--text-tertiary)]">{label}</dt>
{/* The draft sets the value itself in the page's primary tone, a step
above the label. */}
<dd className="body-lg-bold text-[var(--text-primary)]">{children}</dd>
</div>
);
}
const LINK_CLASS =
"text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]";
export default function Contact({
loaderData,
actionData,
@@ -292,6 +359,13 @@ export default function Contact({
const t = createTranslator(locale);
const copy = pageCopy(locale).contact;
/* The CMS wins; until an editor fills the row, the project's own confirmed
channel stands in (lib/site-links.ts). */
const channels =
socialLinks.length > 0
? socialLinks
: [{ label: copy.facebookLabel, href: SITE_FACEBOOK_URL, isExternal: true }];
const navigation = useNavigation();
const { pathname } = useLocation();
const isSubmitting = navigation.state === "submitting";
@@ -305,6 +379,32 @@ export default function Contact({
(message): message is string => typeof message === "string",
);
/* The draft draws the consortium addresses as a disclosure list, which the
approved draft also calls for ("optional accordion"). The answers are
Markdown because that is what <Accordion /> renders; every value in them
comes from the CMS, and the whole list is replaced by the localised empty
state while the addresses are still "TBD". */
const consortiumItems: readonly AccordionItem[] = consortiumContacts.map(
(contact) => {
const place = [contact.city, contact.country].filter(Boolean).join(", ");
return {
id: contact.id,
question: contact.organisation,
answer: [
contact.addressLine,
place === "" ? null : place,
contact.email ? `[${contact.email}](mailto:${contact.email})` : null,
contact.phone
? `[${contact.phone}](tel:${contact.phone.replace(/\s+/g, "")})`
: null,
]
.filter((line): line is string => typeof line === "string")
.join("\n\n"),
};
},
);
/* A failed submit re-renders the page with the summary at the top of the
form. Screen-reader users are left wherever they were, and keyboard users
below the fold, so focus is moved onto the summary itself - it is
@@ -320,32 +420,47 @@ export default function Contact({
return (
<>
<PageHero
className="border-b border-[var(--border-subtle)] bg-[var(--surface-sunken)] [--layout-hero-bottom:clamp(2.5rem,5vw,3.75rem)]"
title={hero.headline ?? copy.heroHeadline}
eyebrow={hero.eyebrow ?? t("nav.sections.engagementAndOpportunities")}
eyebrow={hero.eyebrow ?? t("nav.sections.contact")}
lead={hero.subheading ?? copy.lead}
ambient={[{ top: -250, from: "start", offset: "6%", width: 720, height: 560, durationSeconds: 21 }]}
reveal
{...(hero.media ? { media: hero.media } : {})}
/>
<PageSection className="pt-0 md:pt-0">
<div className="grid grid-cols-1 gap-2xl lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<PageSection className="bg-[var(--surface-sunken)]">
{/* Two equal columns, as the draft sets them - the form beside the
channels, each starting at the top of the band. */}
<div className="grid grid-cols-1 items-start gap-2xl lg:grid-cols-2 lg:gap-4xl">
{/* ---------------------------------------------------------------
THE FORM
THE FORM - one raised panel on the sunken band, as the draft has
it. The mechanism underneath is unchanged: a real HTML form post
that the server validates.
--------------------------------------------------------------- */}
<div className="flex flex-col gap-lg">
<SectionHeader
id="contact-form-heading"
title={copy.formHeading}
lead={copy.formIntro}
/>
<div className="sh-reveal sh-reveal--in flex flex-col gap-lg rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-lg md:p-xl">
{/* The draft opens the panel straight onto the fields; the heading
is what labels the form in the outline and for a screen
reader. */}
<h2 id="contact-form-heading" className="sr-only">
{copy.formHeading}
</h2>
{isSuccess ? (
/* The draft swaps the fields for this acknowledgement inside the
same panel - no box of its own. */
<div
className="flex flex-col items-start gap-md rounded-card border border-[var(--border-accent)] bg-[var(--surface-accent-subtle)] p-lg"
className="sh-rise flex flex-col items-start gap-md"
role="status"
>
<p className="body-lg-bold text-[var(--text-primary)]">
<SentMark />
<p className="heading-h3 text-[var(--text-primary)]">
{t("form.successTitle")}
</p>
{/* The draft promises a reply "within five working days"; no
approved document does, so the generic acknowledgement from
the message catalogue stands instead. */}
<p className="body-md text-[var(--text-secondary)]">
{t("form.successBody")}
</p>
@@ -405,7 +520,9 @@ export default function Contact({
/>
</div>
<div className="grid grid-cols-1 gap-lg md:grid-cols-2">
{/* One column at every width - the draft stacks the four
fields above the message. */}
<div className="flex flex-col gap-lg">
<Input
name="name"
id={fieldDomId("name")}
@@ -474,128 +591,102 @@ export default function Contact({
</div>
{/* ---------------------------------------------------------------
CONTACT DETAILS
THE CHANNELS COLUMN
--------------------------------------------------------------- */}
<div className="flex flex-col gap-lg">
<SectionHeader id="contact-details-heading" title={copy.detailsHeading} />
<div className="flex flex-col gap-xl">
<div className="sh-reveal flex flex-col gap-md">
{/* The draft opens this column straight onto the channel rows;
the heading is here for the outline only. */}
<h2 id="contact-details-heading" className="sr-only">
{copy.detailsHeading}
</h2>
{intro ? (
<Prose>
<RichText markdown={intro} />
</Prose>
) : null}
{intro ? (
<Prose>
<RichText markdown={intro} />
</Prose>
) : null}
<dl className="flex flex-col gap-md rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-lg">
<div className="flex flex-col gap-2xs">
<dt className="label-md uppercase text-[var(--text-tertiary)]">
{copy.generalEnquiries}
</dt>
<dd className="body-md text-[var(--text-secondary)]">
<dl className="flex flex-col gap-md">
<Channel label={copy.generalEnquiries}>
{generalEmail ? (
<a
href={`mailto:${generalEmail}`}
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
<a href={`mailto:${generalEmail}`} className={LINK_CLASS}>
{generalEmail}
</a>
) : (
copy.contactPending
/* The approved draft writes the address as a placeholder,
so nothing stands in for it. A sentence is not a channel
value, so it keeps the quieter body tone. */
<span className="body-md text-[var(--text-secondary)]">
{copy.contactPending}
</span>
)}
</dd>
</div>
</Channel>
<div className="flex flex-col gap-2xs">
<dt className="label-md uppercase text-[var(--text-tertiary)]">
{copy.websiteLabel}
</dt>
<dd className="body-md">
<a
href={copy.websiteHref}
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
<Channel label={copy.websiteLabel}>
<a href={copy.websiteHref} className={LINK_CLASS}>
{loaderData.websiteLabel ?? copy.websiteText}
</a>
</dd>
</div>
</Channel>
{socialLinks.length > 0 ? (
<div className="flex flex-col gap-2xs">
<dt className="label-md uppercase text-[var(--text-tertiary)]">
{t("site.followUs")}
</dt>
<dd>
<ul className="flex flex-wrap gap-md body-md">
{socialLinks.map((link) => (
{/* The CMS owns this row; until an editor fills it, the
project's own confirmed Facebook page stands in. */}
{channels.length > 0 ? (
<Channel label={t("site.followUs")}>
<ul className="flex flex-wrap gap-md">
{channels.map((link) => (
<li key={link.href}>
<a
href={link.href}
{...(link.isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
className={LINK_CLASS}
>
{link.label}
</a>
</li>
))}
</ul>
</dd>
</div>
) : null}
</dl>
</Channel>
) : null}
</dl>
</div>
{/* CONSORTIUM CONTACTS - "TBD" in the draft, so the CMS owns them.
The draft labels the list with an accent eyebrow rather than a
section heading, so the heading takes the label role. */}
<div className="sh-reveal flex flex-col gap-sm">
<h2
id="contact-consortium-heading"
className="label-md uppercase text-[var(--text-accent)]"
>
{copy.consortiumHeading}
</h2>
{consortiumItems.length > 0 ? (
<Accordion items={consortiumItems} defaultOpenIndex={null} />
) : (
<EmptyState message={copy.consortiumEmpty} />
)}
</div>
{/* EU VISIBILITY - the funding note the draft prints at the foot of
this column. The emblem and the statement come from the shared
badge, so the wording and the artwork stay contractual. */}
<div
className={`sh-reveal flex items-center gap-md rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-md ${ACCENT_EDGE}`}
>
<EuBadge
size="sm"
statement={t("site.euStatement")}
locale={locale}
surface="dark"
/>
</div>
</div>
</div>
</PageSection>
{/* CONSORTIUM CONTACTS - "TBD" in the draft, so the CMS owns them. */}
<PageSection aria-labelledby="contact-consortium-heading">
<div className="flex flex-col gap-lg">
<SectionHeader
id="contact-consortium-heading"
title={copy.consortiumHeading}
/>
{consortiumContacts.length > 0 ? (
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
{consortiumContacts.map((contact) => (
<li key={contact.id}>
<InfoPanel title={contact.organisation}>
<address className="flex flex-col gap-2xs not-italic">
{contact.addressLine ? <span>{contact.addressLine}</span> : null}
{contact.city || contact.country ? (
<span>
{[contact.city, contact.country]
.filter(Boolean)
.join(", ")}
</span>
) : null}
{contact.email ? (
<a
href={`mailto:${contact.email}`}
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{contact.email}
</a>
) : null}
{contact.phone ? (
<a
href={`tel:${contact.phone.replace(/\s+/g, "")}`}
dir="ltr"
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{contact.phone}
</a>
) : null}
</address>
</InfoPanel>
</li>
))}
</ul>
) : (
<EmptyState message={copy.consortiumEmpty} />
)}
</div>
</PageSection>
</>
);
}
+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>
);
}
+530 -231
View File
@@ -1,38 +1,56 @@
/* ============================================================================
HOME - /en and /ar
----------------------------------------------------------------------------
Figma frame 253:4799 ("Home-en"). The frame is four bands:
The redesigned home page (Claude Design "Home", second drop), carried onto
the project's own design system. Six bands, alternating the two dark
surfaces the way the draft alternates #0B0B0A and #1D1D1B:
Hero header (253:4800) the SHARP-ER lockup, the headline, the CTA
pair and the wide washed picture. The frame's
subheading node is hidden, so the hero carries
no lead unless the CMS supplies one.
About the Programme 253:4835, hidden="true" in the frame. It is
the only place figures appear, so the band
renders ONLY once the CMS has real numbers -
there is no placeholder standing in for it.
Offical launch (361:10853) "The Journey of SHARP-ER Launch": one centred
lead with four bold runs, a three-up photo
strip that bleeds past the page and is
clipped, and three plain centred captions.
Partners (393:14754) "Meet Our Partners", start-aligned, over a
grid of logo-and-name tiles.
Footer owned by the app shell, not by this module
Hero EU badge, an optional status pill, the headline, the lead,
the CTA pair, the wide picture, the brand stripe and the
figures row. Two slow washes drift behind it.
Launch "An important start for the project": the launch lead and
three pictures from the CMS gallery.
Work "Three areas of change" - the three approved pillars, with
a link through to What We Do.
Newsroom three cards from the newsroom, newest first.
FAQs a sticky heading column beside the accordion.
Consortium the three members, then the civil society network logos.
There is no Arabic frame in the file - only the navigation and the footer
are drawn in Arabic - so /ar is this same DOM under `dir="rtl"`, which is
why every inline edge below is logical (ps-/pe-/start-/end-) and never
left/right.
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
--------------------------------------------
* The draft's FAQ column promises a reply "within five working days". That
commitment appears in no approved document, so it is not published
(CLAUDE.md rule 5). Everything else in the draft is verifiable: the four
figures, the ministries at the launch and the three pillars are all in
docs/ or in the bundled draft copy.
* Every list - news, questions, partner logos, launch pictures - is CMS
data, never the draft's sample entries. Each band shows its localised
empty state instead of inventing content.
* The draft's own navigation and footer are ignored: the site ships
<Navigation /> and <Footer /> from the app shell.
Copy is the approved draft in both languages; the pictures, the figures and
the partner list come from Strapi and degrade to a localised empty state
while it is empty.
MOTION: every animation in the draft is here, as classes from
_page-kit/motion.css, plus the figures counting up on arrival. The one
effect deliberately dropped is the coloured glow behind a hovered button or
card.
There is no Arabic frame, so /ar is this same DOM under `dir="rtl"`, which
is why every inline edge below is logical (ps-/pe-/start-/end-).
========================================================================= */
import { useEffect, useLayoutEffect, useState } from "react";
import { Link } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { createTranslator, getMessages, sectionPath } from "~/i18n";
import { Card } from "~/components/ui/Card";
import { EuBadge } from "~/components/ui/EuBadge";
import {
createTranslator,
formatDate,
getMessages,
sectionPath,
type Locale,
} from "~/i18n";
import {
buildMeta,
buildOrganizationJsonLd,
@@ -41,9 +59,12 @@ import {
import { safeHref } from "~/lib/safe-href";
import type { Route } from "./+types/home";
import { Accordion } from "./_page-kit/accordion";
import {
isImageGalleryBlock,
type CmsFaq,
type CmsHomePage,
type CmsNewsArticle,
type CmsPartner,
} from "./_page-kit/cms-types";
import {
@@ -58,30 +79,50 @@ import { pageCopy } from "./_page-kit/copy";
import { routeLocale } from "./_page-kit/locale";
import {
Container,
cx,
EmptyState,
PageHero,
PageSection,
SectionHeader,
StatStrip,
} from "./_page-kit/sections";
/** Partner logos and CSO tiles - a wide but finite grid. */
const PARTNER_PAGE_SIZE = 36;
import "./_page-kit/motion.css";
/**
* The mark the hero draws above the headline (figma 253:4800). The frame paints
* "SHARP" only - 272.58 x 57.65 of ink - not the full SHARP-ER lockup the
* navigation carries, so this is the SHARP wordmark on its own, as vector so it
* stays crisp at any size. `alt` is empty because the navigation's brand link
* already carries the project's accessible name.
*/
const HERO_WORDMARK = {
src: "/assets/sharp-wordmark-inverse.svg",
alt: "",
width: 272,
height: 57,
drawnWidth: 273,
} as const;
/** The network logo wall - a wide but finite grid. */
const PARTNER_PAGE_SIZE = 36;
/** The draft's newsroom band is a straight three-up row. */
const NEWS_PAGE_SIZE = 3;
/** More than a dozen questions is a page of its own, not a home page band. */
const FAQ_PAGE_SIZE = 12;
/** The draft's launch band shows three pictures. */
const LAUNCH_SHOTS = 3;
/** A newsroom card, already localised and safe to serialise to the client. */
interface NewsCard {
id: string;
title: string;
href: string;
excerpt: string | null;
dateLabel: string | null;
organisation: string | null;
image: { src: string; alt: string } | null;
}
function toNewsCard(article: CmsNewsArticle, locale: Locale): NewsCard {
const image = toImage(article.coverImage, "small");
return {
id: article.documentId,
title: article.title,
href: `${sectionPath("news", locale)}/${article.slug}`,
excerpt: article.excerpt ?? null,
/* The CMS holds one date for both languages; the reader's locale decides
how it is written out. */
dateLabel: article.publishDate ? formatDate(article.publishDate, locale) : null,
organisation: article.authorOrganisation?.name ?? null,
image: image ? { src: image.src, alt: image.alt } : null,
};
}
export async function loader({ params }: Route.LoaderArgs) {
const locale = routeLocale(params);
@@ -93,18 +134,19 @@ export async function loader({ params }: Route.LoaderArgs) {
stats: true,
// Strapi 5 populates a dynamic zone per component with `on`.
blocks: { on: { "blocks.image-gallery": { populate: { images: true } } } },
featuredNews: { populate: { coverImage: true, authorOrganisation: true } },
partners: { populate: { logo: true, logoDark: true } },
seo: { populate: { ogImage: true } },
},
});
/* The curated relation wins; otherwise show every partner the CMS knows, so
the grid fills in as soon as partners exist - before anyone remembers to
the wall fills in as soon as partners exist - before anyone remembers to
curate the home page itself. */
const curated = list(page?.partners);
const curatedPartners = list(page?.partners);
const partners =
curated.length > 0
? curated
curatedPartners.length > 0
? curatedPartners
: await loadMany<CmsPartner>("partners", {
locale,
sort: ["name:asc"],
@@ -113,6 +155,25 @@ export async function loader({ params }: Route.LoaderArgs) {
pagination: { pageSize: PARTNER_PAGE_SIZE },
});
/* Same rule for the newsroom: an editor's picks, else simply the newest
articles, so the band is never empty while the newsroom has entries. */
const curatedNews = list(page?.featuredNews);
const news =
curatedNews.length > 0
? curatedNews
: await loadMany<CmsNewsArticle>("news-articles", {
locale,
sort: ["publishDate:desc", "title:asc"],
populate: { coverImage: true, authorOrganisation: true },
pagination: { pageSize: NEWS_PAGE_SIZE },
});
const faqs = await loadMany<CmsFaq>("faqs", {
locale,
sort: ["order:asc", "createdAt:asc"],
pagination: { pageSize: FAQ_PAGE_SIZE },
});
const gallery = list(page?.blocks)
.filter(isImageGalleryBlock)
.flatMap((block) => list(block.images))
@@ -132,13 +193,20 @@ export async function loader({ params }: Route.LoaderArgs) {
variant: cta.variant ?? "primary",
})),
},
/* Editor-owned figures win; the bundled four stand in until then. */
stats: list(page?.stats).map((stat) => ({
value: stat.value,
label: stat.label,
})),
gallery,
gallery: gallery.slice(0, LAUNCH_SHOTS),
partnersHeadline: page?.partnersHeadline ?? null,
partners: partners.map(toPartner),
news: news.slice(0, NEWS_PAGE_SIZE).map((article) => toNewsCard(article, locale)),
faqs: faqs.map((faq) => ({
id: faq.documentId,
question: faq.question,
answer: faq.answer,
})),
seo: {
title: page?.seo?.metaTitle ?? null,
description: page?.seo?.metaDescription ?? null,
@@ -171,6 +239,10 @@ export function meta({ loaderData, matches }: Route.MetaArgs) {
});
}
/* ---------------------------------------------------------------------------
SMALL PIECES
------------------------------------------------------------------------ */
interface HeroCtaProps {
href: string;
label: string;
@@ -178,12 +250,13 @@ interface HeroCtaProps {
}
/**
* A hero call to action. Figma paints the secondary one as a white fill with a
* black stroke and a dark label, which is the secondary variant on the LIGHT
* surface - so the surface is flipped on the control itself. It has to be the
* control and not a wrapper: `[data-surface]` paints a background in the base
* layer, and a wrapping element would draw a square white box behind the
* button's rounded corners.
* A hero call to action.
*
* Both controls are the project's Button, which now carries the Claude Design
* drafts' own treatment: the accent fill for the primary, the outline ring for
* the secondary, and a 3px lift under the pointer. The secondary is no longer
* flipped onto the light surface - the drafts draw it as an outline on the
* dark band, not as a white fill.
*/
function HeroCta({ href, label, variant }: HeroCtaProps) {
return (
@@ -193,7 +266,6 @@ function HeroCta({ href, label, variant }: HeroCtaProps) {
prefetch="intent"
size="lg"
variant={variant}
{...(variant === "secondary" ? { "data-surface": "light" } : {})}
iconEnd={variant === "primary" ? <ArrowIcon /> : undefined}
>
{label}
@@ -202,70 +274,160 @@ function HeroCta({ href, label, variant }: HeroCtaProps) {
}
/**
* The three-up strip under the launch lead (figma 361:11115). The row is drawn
* 1849px wide against a 1440px page, so it deliberately overflows the page and
* is clipped by the band - the widths below are the designed proportions of the
* 1440 artboard (500 / 729 / 500) carried over as viewport fractions, which
* reproduces the overhang at any width. The centre picture is the emphasised
* one; the flanks sit at half strength and are dropped on small screens, where
* three clipped pictures would be unreadable.
* The hero's status line. The draft hard-codes "Now live · Expression of
* Interest open"; here the words are the CMS hero eyebrow, so the pill only
* ever announces a state an editor has actually written - and the hero simply
* has no pill until then. The dot pulses; the text does not move.
*/
function LaunchStrip({ images }: { images: readonly ResolvedImage[] }) {
const first = images[0];
if (!first) return null;
function StatusPill({ label }: { label: string }) {
return (
<span className="inline-flex items-center gap-xs rounded-pill border border-[var(--border-subtle)] bg-[var(--surface-raised)] py-2xs ps-sm pe-md">
<span
aria-hidden="true"
className="sh-pulse size-[7px] shrink-0 rounded-pill bg-[var(--surface-accent)]"
/>
<span className="label-md uppercase text-[var(--text-secondary)]">{label}</span>
</span>
);
}
/* The flanks only exist as a trio. With one or two pictures the emphasised
one stays centred and the row simply has no wings, rather than throwing
the centre off-axis or hiding an uploaded picture below `lg`. */
const complete = images.length >= 3;
const centre = complete ? images[1]! : first;
const flanks = complete
? { start: images[0]!, end: images[2]! }
: { start: undefined, end: undefined };
/* `useLayoutEffect` would warn during SSR, and the count only exists in a
browser, so the effect is picked per environment. */
const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
/**
* A figure that counts up to its value on arrival, as the draft does.
*
* The server renders the FINAL value, so a reader without JavaScript - and a
* search engine - sees the real figure. In a browser the count is reset before
* the first paint (layout effect, not a plain effect), so the number never
* flashes its end state first. A value the CMS wrote as words rather than
* digits is simply printed.
*/
function CountUpValue({ value }: { value: string }) {
const parsed = /^(\d[\d,]*)([\s\S]*)$/.exec(value);
const target = parsed ? Number(parsed[1]!.replace(/,/g, "")) : null;
const suffix = parsed ? parsed[2]! : "";
const [shown, setShown] = useState<number | null>(target);
useBrowserLayoutEffect(() => {
if (target === null) return;
/* app.css collapses animation, but this one is script-driven, so the
preference has to be honoured here as well. */
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let frame = 0;
const started = performance.now();
const duration = 1500;
setShown(0);
const tick = (now: number) => {
const progress = Math.min(1, (now - started) / duration);
/* Ease out cubic - the draft's own curve. */
setShown(Math.round(target * (1 - Math.pow(1 - progress, 3))));
if (progress < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [target]);
if (target === null) return <>{value}</>;
return (
<ul className="flex list-none items-center justify-center gap-3xl">
{flanks.start ? (
<li className="hidden shrink-0 lg:block">
<img
src={flanks.start.src}
alt={flanks.start.alt}
loading="lazy"
className="aspect-[500/268] w-[34.7vw] object-cover opacity-50"
/>
</li>
) : null}
<>
{shown}
{suffix}
</>
);
}
<li className="shrink-0">
<img
src={centre.src}
alt={centre.alt}
loading="lazy"
className="aspect-[729/374] w-[86vw] object-cover lg:w-[50.6vw]"
/>
</li>
/**
* The figures row that closes the hero: four cells divided by hairlines. The
* grid's own background paints the 1px gaps, which is how the draft draws the
* dividers without a border on each cell.
*/
function StatsRow({ stats }: { stats: readonly { value: string; label: string }[] }) {
if (stats.length === 0) return null;
{flanks.end ? (
<li className="hidden shrink-0 lg:block">
<img
src={flanks.end.src}
alt={flanks.end.alt}
loading="lazy"
className="aspect-[500/267] w-[34.7vw] object-cover opacity-50"
/>
return (
<dl className="grid grid-cols-1 gap-px border-y border-[var(--border-subtle)] bg-[var(--border-subtle)] sm:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
/* Source order is term-then-description, as the spec requires; the
reversed column paints the figure above its label without repeating
the label for a screen reader. */
<div
key={`${stat.value}-${stat.label}`}
className="flex flex-col-reverse gap-2xs bg-[var(--surface-sunken)] px-lg py-xl text-start transition-colors duration-[var(--motion-duration-base)] hover:bg-[var(--surface-raised)]"
>
<dt className="label-md uppercase text-[var(--text-tertiary)]">{stat.label}</dt>
<dd className="heading-h1 tabular-nums text-[var(--text-primary)]">
<CountUpValue value={stat.value} />
</dd>
</div>
))}
</dl>
);
}
/**
* The launch band's three pictures. The CMS alt text doubles as the visible
* caption - so the picture is described once, in the caption, and the <img>
* itself is marked decorative rather than having a screen reader read the same
* sentence twice.
*/
function LaunchShots({ images }: { images: readonly ResolvedImage[] }) {
if (images.length === 0) return null;
return (
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
{images.map((image) => (
<li key={image.src} className="sh-reveal">
<figure className="flex flex-col gap-xs">
<img
src={image.src}
alt=""
loading="lazy"
className="sh-lift h-[15rem] w-full rounded-control border border-[var(--border-subtle)] object-cover hover:border-[var(--border-accent)]"
/>
{image.alt ? (
<figcaption className="body-sm-bold text-[var(--text-secondary)]">
{image.alt}
</figcaption>
) : null}
</figure>
</li>
) : null}
))}
</ul>
);
}
/** The "see the whole section" link the draft puts opposite a band heading. */
function BandLink({ to, label }: { to: string; label: string }) {
return (
<Button as={Link} to={to} prefetch="intent" variant="ghost" iconEnd={<ArrowIcon />}>
{label}
</Button>
);
}
/* ---------------------------------------------------------------------------
THE PAGE
------------------------------------------------------------------------ */
export default function Home({ loaderData }: Route.ComponentProps) {
const { locale, hero, stats, gallery, partners } = loaderData;
const { locale, hero, stats, gallery, partners, news, faqs } = loaderData;
const t = createTranslator(locale);
const copy = pageCopy(locale).home;
/* The three pillars are the About page's approved copy - one source, so the
two pages can never drift apart. */
const pillars = pageCopy(locale).about.pillars;
const headline = hero.headline ?? copy.heroHeadline;
const partnersHeadline = loaderData.partnersHeadline ?? copy.partnersHeading;
const lead = hero.subheading ?? copy.heroLead;
const figures = stats.length > 0 ? stats : copy.stats;
const consortiumHeading = loaderData.partnersHeadline ?? copy.consortiumHeading;
/* The frame draws one accent CTA and one white one. Editor-supplied CTAs go
through the same component as the fallback pair, so a `secondary` CTA
@@ -279,165 +441,302 @@ export default function Home({ loaderData }: Route.ComponentProps) {
}))
: [
{
href: sectionPath("about", locale),
label: t("actions.discoverSharper"),
href: sectionPath("applications", locale),
label: copy.heroCtaPrimary,
variant: "primary",
},
{
href: sectionPath("whatWeDo", locale),
label: t("actions.exploreOurWork"),
href: sectionPath("about", locale),
label: copy.heroCtaSecondary,
variant: "secondary",
},
];
return (
<>
{/* `lead` is passed only when the CMS supplies a subheading: the frame's
own subheading node is hidden, so nothing stands under the headline
unless an editor deliberately writes one. */}
<PageHero
title={headline}
{...(hero.eyebrow ? { eyebrow: hero.eyebrow } : {})}
{...(hero.subheading ? { lead: hero.subheading } : {})}
wordmark={HERO_WORDMARK}
{...(hero.media ? { media: hero.media } : {})}
mediaTreatment="banner"
actions={
heroCtas.map((cta) => (
<HeroCta key={`${cta.href}-${cta.label}`} {...cta} />
))
}
{/* The reading-progress rule. Decorative, pointer-transparent, and
painted only where scroll timelines exist (motion.css). */}
<div
aria-hidden="true"
className="sh-progress pointer-events-none fixed inset-x-0 top-0 z-[var(--layer-dropdown)] h-[3px]"
/>
{/* FIGURES - 253:4835 is hidden in the frame and every figure in the
draft is marked TBD, so the band exists only once the CMS has real
numbers. Nothing is rendered in its place. */}
{stats.length > 0 ? (
<PageSection aria-labelledby="home-stats-heading">
<div className="flex flex-col gap-3xl">
<SectionHeader
id="home-stats-heading"
title={copy.statsHeading}
size="display"
/>
<StatStrip stats={stats} />
</div>
</PageSection>
) : null}
<PageHero
className="bg-[var(--surface-sunken)] [--layout-hero-top:clamp(2.5rem,6vw,4.5rem)] [--layout-hero-bottom:var(--spacing-2xl)]"
title={headline}
lead={lead}
leadWithActions
{...(hero.eyebrow ? { statusPill: <StatusPill label={hero.eyebrow} /> } : {})}
ambient={[
{ top: -220, from: "start", offset: "8%", width: 780, height: 620, durationSeconds: 18 },
{
top: 120,
from: "end",
offset: "-160px",
width: 620,
height: 520,
tone: "danger",
durationSeconds: 24,
reverse: true,
},
]}
reveal
/* EU visibility sits at the top of the hero (the draft's badge). The
emblem is the dark-surface export because the hero sits on the
page's own dark surface. `emblemAlt` is left at its empty default:
the statement is real text beside the emblem, so naming the image
would read the attribution out twice. */
funderBadge={
<EuBadge
size="sm"
statement={t("site.euStatement")}
locale={locale}
surface="dark"
className="self-start"
/>
}
{...(hero.media ? { media: hero.media } : {})}
mediaTreatment="banner"
actions={heroCtas.map((cta) => (
<HeroCta key={`${cta.href}-${cta.label}`} {...cta} />
))}
below={<StatsRow stats={figures} />}
/>
{/* THE LAUNCH STORY - the band is `bleed` so the photo strip can run past
the content column, and it clips what runs past the page. */}
{/* THE LAUNCH */}
<PageSection
aria-labelledby="home-launch-heading"
bleed
className="overflow-hidden"
band
>
<div className="flex flex-col gap-3xl">
<Container>
<div className="flex flex-col items-center gap-lg text-center">
<SectionHeader
id="home-launch-heading"
title={copy.launchHeading}
size="display"
align="center"
/>
<div className="sh-reveal mx-auto flex max-w-[55rem] flex-col items-center gap-lg text-center">
<SectionHeader
id="home-launch-heading"
eyebrow={copy.launchEyebrow}
title={copy.launchHeading}
size="display"
align="center"
/>
{/* One paragraph with four bold runs, as the frame sets it. The
designed 20px has no published type role, so it takes the
nearest one, Body/Large - whose 28px leading is the frame's. */}
<p className="body-lg text-[var(--text-primary)]">
{copy.launchLead.map((run, index) =>
typeof run === "string" ? (
<span key={index}>{run}</span>
) : (
<strong key={index} className="body-lg-bold">
{run.em}
</strong>
),
)}
</p>
</div>
</Container>
<p className="body-lg text-[var(--text-secondary)]">{copy.launchLead}</p>
</div>
{gallery.length > 0 ? <LaunchStrip images={gallery} /> : null}
<Container>
{/* Figma prints these three captions with no heading of their own;
the heading exists for the outline and for screen readers. */}
<h3 id="home-mechanisms-heading" className="sr-only">
{copy.mechanismsHeading}
</h3>
<ul
aria-labelledby="home-mechanisms-heading"
className="grid list-none grid-cols-1 gap-lg text-center sm:grid-cols-2 lg:grid-cols-3"
>
{copy.mechanisms.map((mechanism) => (
<li
key={mechanism}
className="mx-auto max-w-[21.875rem] heading-h2 text-[var(--text-primary)]"
>
{mechanism}
</li>
))}
</ul>
</Container>
<LaunchShots images={gallery} />
</div>
</PageSection>
{/* PARTNERS */}
<PageSection aria-labelledby="home-partners-heading">
<div className="flex flex-col gap-3xl">
<SectionHeader
id="home-partners-heading"
title={partnersHeadline}
size="display"
/>
{/* THREE AREAS OF CHANGE */}
<PageSection
aria-labelledby="home-work-heading"
className="bg-[var(--surface-sunken)]"
>
<div className="flex flex-col gap-2xl">
<div className="sh-reveal flex flex-wrap items-end justify-between gap-lg border-b border-[var(--border-default)] pb-lg">
<SectionHeader
id="home-work-heading"
title={copy.workHeading}
size="display"
/>
{partners.length > 0 ? (
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3 lg:gap-x-4xl">
{partners.map((partner) => (
/* The tile pads its inline start only, as the frame draws it,
and sits on the sunken surface - darker than the page, which
is the way round the design has it. */
<li
key={partner.id}
className="flex min-h-[7.125rem] items-center gap-md rounded-control bg-[var(--surface-sunken)] ps-md py-sm"
>
{partner.logo ? (
<img
src={partner.logo.src}
alt=""
loading="lazy"
className="size-[4.375rem] shrink-0 object-contain"
/>
) : null}
<BandLink to={sectionPath("whatWeDo", locale)} label={copy.workLinkLabel} />
</div>
{/* An editor-supplied URL: `safeHref` refuses anything
that is not http/https/mailto/tel, and the plain-text
branch below already renders the partner's name. */}
{safeHref(partner.website) ? (
<a
href={safeHref(partner.website)}
target="_blank"
rel="noopener noreferrer"
className="body-lg-bold text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{partner.name}
</a>
) : (
<span className="body-lg-bold text-[var(--text-primary)]">
{partner.name}
</span>
)}
</li>
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
{pillars.map((pillar) => (
<li
key={pillar.title}
className="sh-lift sh-reveal sh-reveal--in flex h-full flex-col gap-sm rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl hover:border-[var(--border-accent)]"
>
<h3 className="heading-h3 text-[var(--text-primary)]">{pillar.title}</h3>
<p className="body-md text-[var(--text-secondary)]">
{pillar.description}
</p>
</li>
))}
</ul>
</div>
</PageSection>
{/* NEWSROOM */}
<PageSection
aria-labelledby="home-news-heading"
band
>
<div className="flex flex-col gap-2xl">
<div className="sh-reveal flex flex-wrap items-end justify-between gap-lg border-b border-[var(--border-default)] pb-lg">
<SectionHeader
id="home-news-heading"
eyebrow={copy.newsEyebrow}
title={copy.newsHeading}
size="display"
/>
<BandLink to={sectionPath("news", locale)} label={t("actions.viewAll")} />
</div>
{news.length > 0 ? (
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
{news.map((item) => (
<Card.Content
key={item.id}
as="li"
className="sh-reveal sh-reveal--in"
href={item.href}
title={item.title}
actionLabel={t("actions.readMore")}
{...(item.organisation
? { eyebrow: t("news.postedBy", { organisation: item.organisation }) }
: item.dateLabel
? { eyebrow: item.dateLabel }
: {})}
{...(item.organisation && item.dateLabel
? { badge: item.dateLabel }
: {})}
{...(item.excerpt ? { excerpt: item.excerpt } : {})}
{...(item.image ? { image: item.image } : {})}
titleAs="h3"
/>
))}
</ul>
) : (
<EmptyState message={copy.partnersEmpty} />
<EmptyState message={copy.newsEmpty} />
)}
</div>
</PageSection>
{/* FAQs - the heading column stays with the reader while the answers
scroll beside it, as the draft has it. */}
<PageSection
aria-labelledby="home-faq-heading"
className="bg-[var(--surface-sunken)]"
>
<div className="grid grid-cols-1 gap-2xl lg:grid-cols-2 lg:gap-4xl">
<div className="sh-reveal flex flex-col gap-lg lg:sticky lg:top-[var(--layout-sticky-top)] lg:self-start">
<SectionHeader
id="home-faq-heading"
eyebrow={copy.faqEyebrow}
title={copy.faqHeading}
lead={copy.faqLead}
size="display"
/>
<Button
as={Link}
to={sectionPath("knowledgeHub", locale)}
prefetch="intent"
size="lg"
className="self-start"
iconEnd={<ArrowIcon />}
>
{copy.faqCtaLabel}
</Button>
</div>
{faqs.length > 0 ? (
<Accordion items={faqs} className="sh-reveal" />
) : (
<EmptyState message={copy.faqEmpty} />
)}
</div>
</PageSection>
{/* THE CONSORTIUM, then the network it works with */}
<PageSection
aria-labelledby="home-consortium-heading"
band
>
<div className="flex flex-col gap-2xl">
<div className="sh-reveal flex flex-wrap items-end justify-between gap-lg">
<SectionHeader
id="home-consortium-heading"
title={consortiumHeading}
size="display"
/>
<p className="label-md uppercase text-[var(--text-tertiary)]">
{t("site.euStatement")}
</p>
</div>
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
{copy.consortium.map((member) => (
<li
key={member.name}
className="sh-lift sh-reveal sh-reveal--in flex h-full flex-col items-start gap-sm rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-xl hover:border-[var(--border-accent)]"
>
<span className="rounded-control bg-[var(--surface-accent-subtle)] px-xs py-2xs label-md uppercase text-[var(--text-accent)]">
{member.role}
</span>
<h3 className="heading-h3 text-[var(--text-primary)]">{member.name}</h3>
<p className="body-md text-[var(--text-secondary)]">
{member.description}
</p>
</li>
))}
</ul>
<div className="flex flex-col gap-md">
<h3 className="label-md uppercase text-[var(--text-tertiary)]">
{copy.networkLabel}
</h3>
{partners.length > 0 ? (
<ul className="grid list-none grid-cols-2 gap-md sm:grid-cols-3 lg:grid-cols-4">
{partners.map((partner) => {
const href = safeHref(partner.website);
/* The logo wall is a grid of equal tiles; the name is the
tile's text when there is no logo to draw, and always its
accessible name. */
const inner = (
<>
{partner.logo ? (
<img
src={partner.logo.src}
alt={partner.name}
loading="lazy"
className="max-h-[3.5rem] w-auto max-w-full object-contain"
/>
) : (
<span className="body-sm-bold text-center text-[var(--text-secondary)]">
{partner.name}
</span>
)}
</>
);
return (
<li
key={partner.id}
className={cx(
"sh-reveal sh-reveal--in flex h-[5.5rem] items-center justify-center",
"rounded-control border border-[var(--border-subtle)] bg-[var(--surface-card)] p-md",
"transition-colors duration-[var(--motion-duration-base)]",
"hover:border-[var(--border-accent)]",
)}
>
{href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="flex h-full w-full items-center justify-center"
>
{inner}
</a>
) : (
inner
)}
</li>
);
})}
</ul>
) : (
<EmptyState message={copy.partnersEmpty} />
)}
</div>
</div>
</PageSection>
</>
);
}
+289 -100
View File
@@ -4,9 +4,17 @@
/{locale}/knowledge-hub
§7 of docs/SHARPER_Website_Draft_Section_EN_AR.docx: training modules,
toolkits, Civil Society Law Session guides, case studies and FAQs. Browsing
is by type, by tag and by free-text search, all three held in the query
string so a browse is a shareable, crawlable URL:
toolkits, Civil Society Law Session guides, case studies and FAQs, laid out
to the 2026 redesign draft ("Knowledge-Hub"):
hero the search box the ToR asks for, in the headline band
collections the five kinds of material as numbered tiles, each carrying
the number of items published under it
FAQs a sticky heading column beside the accordion
repository the closing note on long-term storage
Browsing is by type, by tag and by free-text search, all three held in the
query string so a browse is a shareable, crawlable URL:
?type=case-study&topic=access-to-justice&q=municipality
The ?type= values are the `knowledge-item.itemType` enumeration in the CMS,
@@ -16,17 +24,30 @@
listing and the news archive filter on - so a tag means the same thing
everywhere on the site instead of drifting per section.
While the hub is empty the four type cards still describe what will land in
it, using the draft's own one-line descriptions; nothing is invented and no
count is shown.
WHAT COMES FROM WHERE
* the five type names and their one-line descriptions are approved draft
copy and live in ./_shared/page-copy;
* the COUNT on each tile is Strapi's own count. The draft's "12 modules ·
EN / AR", "6 guides", "9 case studies" were sample text and are not
shipped;
* the FAQ band reads api::faq.faq. The draft writes out five questions and
answers - on the rule of law, SUQ, eligibility, P2R and reuse - and not
one of them appears in an approved document, so none of them is
published here. Until an editor writes them the band shows its localised
"being prepared" line;
* the repository note is the ToR's own wording (§2 and §3.3), not the
draft's stronger claim that every item already is versioned and kept
beyond the project's end.
========================================================================= */
import { Link, useLocation } from "react-router";
import { Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { Card, CardActionHint, CardLink, CardShell } from "~/components/ui/Card";
import {
formatDate,
formatNumber,
interpolate,
sectionPath,
useTranslation,
type Locale,
@@ -34,10 +55,15 @@ import {
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/knowledge-hub";
import { Accordion } from "./_page-kit/accordion";
import type { CmsFaq } from "./_page-kit/cms-types";
import { loadMany } from "./_page-kit/cms.server";
import { Container } from "./_page-kit/sections";
import {
CMS,
KNOWLEDGE_TYPES,
fetchCollection,
fetchCount,
fetchFilterOptions,
toImage,
type CmsImage,
@@ -56,23 +82,30 @@ import {
withParam,
} from "./_shared/listing";
import {
CalloutBand,
EmptyState,
FilterBar,
FilterSearch,
FilterSelect,
PageHeader,
FilterPills,
ListingHero,
PageShell,
Pagination,
PillSelect,
ResultSummary,
SearchForm,
} from "./_shared/listing-ui";
import {
KNOWLEDGE_COPY,
KNOWLEDGE_TYPE_BLURBS,
KNOWLEDGE_TYPE_LABELS,
getListingChrome,
} from "./_shared/page-copy";
import "./_page-kit/motion.css";
const DATE_FIELD = "publicationDate";
/** More than a dozen questions is a page of its own, not one band. */
const FAQ_PAGE_SIZE = 12;
/** The label for one `itemType`, or the raw value if a new one is added. */
function typeLabel(value: string, locale: Locale): string {
return KNOWLEDGE_TYPE_LABELS[locale][value] ?? value;
@@ -111,7 +144,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
: undefined,
});
const [listing, topicOptions] = await Promise.all([
const [listing, topicOptions, typeCounts, faqs] = await Promise.all([
fetchCollection<KnowledgeEntry>(CMS.knowledgeItems, {
locale,
filters,
@@ -125,6 +158,18 @@ export async function loader({ params, request }: Route.LoaderArgs) {
pagination: { page, pageSize: PAGE_SIZE, withCount: true },
}),
fetchFilterOptions(CMS.topics, locale),
/* One count per type card. Strapi's own figure, so a card can never
promise material that has not been published. */
Promise.all(
KNOWLEDGE_TYPES.map((value) =>
fetchCount(CMS.knowledgeItems, locale, { itemType: { $eq: value } }),
),
),
loadMany<CmsFaq>("faqs", {
locale,
sort: ["order:asc", "createdAt:asc"],
pagination: { pageSize: FAQ_PAGE_SIZE },
}),
]);
const entries: KnowledgeListItem[] = listing.entries.map((entry) =>
@@ -147,12 +192,20 @@ export async function loader({ params, request }: Route.LoaderArgs) {
})),
topics: topicOptions,
},
/** The five type cards, with the draft's descriptions. */
browse: KNOWLEDGE_TYPES.map((value) => ({
/** The five type cards, with the draft's descriptions and a real count. */
browse: KNOWLEDGE_TYPES.map((value, index) => ({
value,
label: typeLabel(value, locale),
blurb: KNOWLEDGE_TYPE_BLURBS[locale][value] ?? "",
count: typeCounts[index] ?? 0,
})),
faqs: faqs
.filter((faq) => Boolean(faq?.question && faq?.answer))
.map((faq) => ({
id: faq.documentId,
question: faq.question,
answer: faq.answer,
})),
};
}
@@ -203,107 +256,243 @@ export function meta({ matches, loaderData }: Route.MetaArgs) {
------------------------------------------------------------------------ */
export default function KnowledgeHub({ loaderData }: Route.ComponentProps) {
const { entries, pagination, filters, facets, browse } = loaderData;
const { entries, pagination, filters, facets, browse, faqs } = loaderData;
const { t, locale } = useTranslation();
const { pathname, search } = useLocation();
const params = new URLSearchParams(search);
const copy = KNOWLEDGE_COPY[locale];
const chrome = getListingChrome(locale).knowledge;
const isFiltered = Boolean(filters.type || filters.topic || filters.q);
const typePills = [
{
id: "all",
label: t("filters.all"),
href: `${pathname}${withParam(params, "type", undefined)}`,
isCurrent: filters.type === "",
},
...facets.types.map((option) => ({
id: option.value,
label: option.label,
href: `${pathname}${withParam(params, "type", option.value)}`,
isCurrent: filters.type === option.value,
})),
];
const keep = [...params.entries()]
.filter(([key]) => key !== "q" && key !== "page")
.map(([key, value]) => [key, value] as const);
return (
<PageShell>
<PageHeader heading={copy.heading} intro={copy.intro} />
{/* The map of the hub. Hidden once a filter is on, where it would just
repeat the control the reader has already used. */}
{!isFiltered ? (
<nav aria-label={t("nav.sections.knowledgeHub")}>
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
{browse.map((item) => (
<Card.Content
key={item.value}
as="li"
titleAs="h2"
href={`${pathname}${withParam(new URLSearchParams(), "type", item.value)}`}
title={item.label}
excerpt={item.blurb}
actionLabel={t("actions.browseHub")}
/>
))}
</ul>
</nav>
) : null}
<section className="flex flex-col gap-lg" aria-label={t("filters.heading")}>
<FilterBar
legend={t("filters.heading")}
<>
<ListingHero
eyebrow={chrome.eyebrow}
heading={copy.heading}
intro={copy.intro}
ambient={[{ top: -250, from: "start", offset: "10%", width: 740, height: 570, durationSeconds: 21 }]}
>
<SearchForm
action={pathname}
applyLabel={t("filters.apply")}
clearLabel={t("filters.clear")}
clearHref={pathname}
showClear={isFiltered}
>
<FilterSearch
label={t("search.label")}
placeholder={t("search.placeholder")}
defaultValue={filters.q}
/>
<FilterSelect
name="type"
label={t("filters.category")}
value={filters.type}
options={facets.types}
allLabel={t("filters.all")}
/>
<FilterSelect
name="topic"
label={t("filters.topic")}
value={filters.topic}
options={facets.topics}
allLabel={t("filters.all")}
/>
</FilterBar>
label={t("search.label")}
placeholder={chrome.searchPlaceholder}
defaultValue={filters.q}
submitLabel={t("search.submit")}
keep={keep}
className="max-w-[var(--layout-prose-max)]"
/>
</ListingHero>
{filters.q ? (
<p className="body-md text-text-secondary">
{t("search.resultsFor", { query: filters.q })}
</p>
<PageShell>
{/* The map of the hub. Hidden once a filter is on, where it would just
repeat the control the reader has already used. */}
{!isFiltered ? (
<nav aria-label={chrome.browseLabel} className="flex flex-col gap-lg">
<h2 className="sr-only">{chrome.browseLabel}</h2>
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
{browse.map((item, index) => (
<CollectionTile
key={item.value}
index={index}
label={item.label}
blurb={item.blurb}
count={item.count}
countTemplate={chrome.itemCount}
actionLabel={t("actions.browseHub")}
href={`${pathname}${withParam(new URLSearchParams(), "type", item.value)}`}
locale={locale}
/>
))}
</ul>
</nav>
) : null}
<ResultSummary pagination={pagination} t={t} />
<section className="flex flex-col gap-lg" aria-label={t("filters.heading")}>
<div className="flex flex-wrap items-center justify-between gap-lg border-b border-border-subtle pb-lg">
<FilterPills label={t("filters.category")} pills={typePills} />
{entries.length === 0 ? (
<EmptyState
title={isFiltered ? t("filters.noResults") : copy.emptyTitle}
body={isFiltered ? t("filters.noResultsHint") : copy.emptyBody}
>
{isFiltered ? (
<Button as={Link} to={pathname} variant="secondary" size="sm">
{t("filters.clear")}
</Button>
) : null}
</EmptyState>
) : (
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
{entries.map((entry) => (
<Card.Content
key={entry.id}
as="li"
href={`${sectionPath("knowledgeHub", locale)}/${entry.slug}`}
title={entry.title}
actionLabel={t("actions.readMore")}
{...(entry.typeLabel ? { eyebrow: entry.typeLabel } : {})}
{...(entry.summary ? { excerpt: entry.summary } : {})}
{...(entry.dateLabel ? { badge: entry.dateLabel } : {})}
{...(entry.image ? { image: entry.image } : {})}
{facets.topics.length > 0 ? (
<PillSelect
name="topic"
label={t("filters.topic")}
value={filters.topic}
options={facets.topics}
allLabel={t("filters.all")}
applyLabel={t("filters.apply")}
action={pathname}
keep={[
...(filters.type ? [["type", filters.type] as const] : []),
...(filters.q ? [["q", filters.q] as const] : []),
]}
/>
))}
</ul>
)}
) : null}
</div>
<Pagination model={pagination} pathname={pathname} params={params} t={t} />
{filters.q ? (
<p className="body-md text-text-secondary">
{t("search.resultsFor", { query: filters.q })}
</p>
) : null}
<ResultSummary pagination={pagination} t={t} />
{entries.length === 0 ? (
<EmptyState
title={isFiltered ? t("filters.noResults") : copy.emptyTitle}
body={isFiltered ? t("filters.noResultsHint") : copy.emptyBody}
>
{isFiltered ? (
<Button as={Link} to={pathname} variant="secondary" size="sm">
{t("filters.clear")}
</Button>
) : null}
</EmptyState>
) : (
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
{entries.map((entry) => (
<Card.Content
key={entry.id}
as="li"
className="sh-reveal sh-reveal--in"
href={`${sectionPath("knowledgeHub", locale)}/${entry.slug}`}
title={entry.title}
actionLabel={t("actions.readMore")}
{...(entry.typeLabel ? { eyebrow: entry.typeLabel } : {})}
{...(entry.summary ? { excerpt: entry.summary } : {})}
{...(entry.dateLabel ? { badge: entry.dateLabel } : {})}
{...(entry.image ? { image: entry.image } : {})}
/>
))}
</ul>
)}
<Pagination model={pagination} pathname={pathname} params={params} t={t} />
</section>
</PageShell>
{/* The FAQ band. The draft lifts it off the #0B0B0A ground with
#1D1D1B, so the surface is painted rather than inherited. */}
<section
aria-labelledby="hub-faqs"
className="sh-band py-3xl md:py-4xl"
>
<Container>
<div className="grid gap-2xl lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
<div className="sh-reveal flex flex-col gap-sm lg:sticky lg:top-[var(--layout-sticky-top)]">
<p className="label-md uppercase text-text-accent">
{chrome.faqEyebrow}
</p>
<h2 id="hub-faqs" className="heading-h2 text-text-primary">
{chrome.faqHeading}
</h2>
<p className="body-md text-text-secondary">{chrome.faqIntro}</p>
</div>
{faqs.length > 0 ? (
<Accordion items={faqs} />
) : (
<EmptyState title={chrome.faqHeading} body={chrome.faqEmpty} />
)}
</div>
</Container>
</section>
</PageShell>
<PageShell>
<CalloutBand
eyebrow={chrome.repositoryEyebrow}
headline={chrome.repositoryHeadline}
action={{
to: sectionPath("resources", locale),
label: chrome.repositoryAction,
}}
/>
</PageShell>
</>
);
}
/* ---------------------------------------------------------------------------
ONE COLLECTION TILE
----------------------------------------------------------------------------
The draft numbers the tiles and closes each with a count and an arrow. The
number is ordinal decoration, so it is hidden from assistive technology -
the tile's accessible name is its title.
------------------------------------------------------------------------ */
function CollectionTile({
index,
label,
blurb,
count,
countTemplate,
actionLabel,
href,
locale,
}: {
index: number;
label: string;
blurb: string;
count: number;
countTemplate: string;
actionLabel: string;
href: string;
locale: Locale;
}) {
const ordinal = formatNumber(index + 1, locale, {
minimumIntegerDigits: 2,
useGrouping: false,
});
return (
<CardShell
as="li"
linked
className="sh-reveal sh-reveal--in flex-col gap-md p-lg"
>
<p aria-hidden="true" className="heading-h2 text-text-accent">
{ordinal}
</p>
<h3 className="sh-card-title body-lg-bold text-text-primary">
<CardLink href={href} className="sh-card-link">
{label}
</CardLink>
</h3>
{blurb ? <p className="body-sm text-text-secondary">{blurb}</p> : null}
<div className="mt-auto flex items-center justify-between gap-sm border-t border-border-subtle pt-sm">
{/* Nothing published yet means no count - a row of zeroes would read
as a fault rather than as an honest figure. */}
{count > 0 ? (
<p className="label-md uppercase text-text-tertiary">
{interpolate(countTemplate, { count: formatNumber(count, locale) })}
</p>
) : (
<span />
)}
<CardActionHint label={actionLabel} />
</div>
</CardShell>
);
}
+260 -73
View File
@@ -3,10 +3,11 @@
----------------------------------------------------------------------------
/{locale}/news-and-updates
§5 of docs/SHARPER_Website_Draft_Section_EN_AR.docx. Two streams, exactly as
the draft describes them:
§5 of docs/SHARPER_Website_Draft_Section_EN_AR.docx, laid out to the 2026
redesign draft ("News-and-Updates"): an ambient hero, the two-stream tab
strip, one wide lead card over a three-up grid, and the partner callout.
Tab 1 SHARP-ER News ?tab=sharper-news official project updates
Tab 1 SHARP-ER News ?tab=sharper-news official project updates
Tab 2 From the Network ?tab=from-the-network updates submitted by CSOs
granted as part of the consortium,
each card crediting the
@@ -18,13 +19,30 @@
The tabs are LINKS, not client-side state: each stream is its own URL, so it
can be shared, bookmarked and crawled, and the server renders exactly what
the address asks for. `aria-current="page"` marks the active one.
NOTHING FROM THE DRAFT'S SAMPLE FEED IS HERE. Every headline, date, tag,
excerpt and byline it shows - the launch event, the Misrata sub-project, the
fact sheet, "30 JUN 2026" - was sample content written for the mockup. Each
card is built from a Strapi entry or the localised empty state renders
instead (CLAUDE.md rule 5).
========================================================================= */
import { Link, useLocation } from "react-router";
import { Button } from "~/components/ui/Button";
import { Card } from "~/components/ui/Card";
import { formatDate, sectionPath, useTranslation, type Locale } from "~/i18n";
import {
CardActionHint,
CardLink,
CardMedia,
CardShell,
} from "~/components/ui/Card";
import {
formatDate,
sectionPath,
toIsoDate,
useTranslation,
type Locale,
} from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/news-and-updates";
@@ -32,6 +50,7 @@ import {
CMS,
NEWS_CATEGORIES,
fetchCollection,
taxonomyNames,
toImage,
type CmsImage,
type NewsCategory,
@@ -48,15 +67,18 @@ import {
withParam,
} from "./_shared/listing";
import {
CalloutBand,
EmptyState,
PageHeader,
ListingHero,
PageShell,
Pagination,
ResultSummary,
TabLinks,
type TabLink,
} from "./_shared/listing-ui";
import { NEWS_COPY } from "./_shared/page-copy";
import { NEWS_COPY, getListingChrome } from "./_shared/page-copy";
import "./_page-kit/motion.css";
/** Tab 1 is the default stream, matching the order of the draft. */
const DEFAULT_TAB: NewsCategory = "sharper-news";
@@ -70,7 +92,10 @@ interface NewsListItem {
readonly title: string;
readonly excerpt?: string;
readonly organisation?: string;
readonly date?: string;
readonly dateLabel?: string;
/** First topic of the entry - the draft's coloured chip. From the CMS only. */
readonly tag?: string;
readonly image?: CmsImage;
}
@@ -95,6 +120,9 @@ export async function loader({ params, request }: Route.LoaderArgs) {
},
// A relation, not a string: without this the byline is simply absent.
authorOrganisation: { fields: ["name", "slug"] },
// Drives the draft's chip above the headline. Nothing is invented: a
// card with no topic simply has no chip.
topics: { fields: ["name", "slug"] },
},
pagination: { page, pageSize: PAGE_SIZE, withCount: true },
});
@@ -112,9 +140,10 @@ export async function loader({ params, request }: Route.LoaderArgs) {
}
function toListItem(entry: NewsEntry, locale: Locale): NewsListItem {
const image = toImage(entry.coverImage, "small");
const image = toImage(entry.coverImage, "medium");
const date = entry.publishDate ?? entry.publishedAt ?? undefined;
const organisation = entry.authorOrganisation?.name;
const tag = taxonomyNames(entry.topics)[0];
return {
id: entry.documentId,
@@ -122,7 +151,8 @@ function toListItem(entry: NewsEntry, locale: Locale): NewsListItem {
title: entry.title,
...(entry.excerpt ? { excerpt: entry.excerpt } : {}),
...(organisation ? { organisation } : {}),
...(date ? { dateLabel: formatDate(date, locale) } : {}),
...(date ? { date, dateLabel: formatDate(date, locale) } : {}),
...(tag ? { tag } : {}),
...(image ? { image } : {}),
};
}
@@ -166,6 +196,7 @@ export default function NewsAndUpdates({ loaderData }: Route.ComponentProps) {
const params = new URLSearchParams(search);
const copy = NEWS_COPY[locale];
const chrome = getListingChrome(locale).news;
const empty = new URLSearchParams();
const tabs: TabLink[] = [
@@ -183,71 +214,227 @@ export default function NewsAndUpdates({ loaderData }: Route.ComponentProps) {
},
];
/* The draft leads the page with one wide card. It is the newest entry of the
stream, and only on page 1 - page 4 of an archive has no "lead story". */
const isFirstPage = pagination.page === 1;
const [lead, ...rest] = entries;
const featured = isFirstPage ? lead : undefined;
const grid = featured ? rest : entries;
const hrefOf = (entry: NewsListItem) =>
`${sectionPath("news", locale)}/${entry.slug}`;
return (
<PageShell>
<PageHeader heading={copy.heading} intro={copy.intro} />
<section className="flex flex-col gap-lg" aria-label={copy.heading}>
<TabLinks label={t("nav.sections.news")} tabs={tabs} />
<p className="body-md max-w-[var(--layout-prose-max)] text-text-secondary">
{tab === "from-the-network" ? copy.networkBlurb : copy.projectBlurb}
</p>
<ResultSummary pagination={pagination} t={t} />
{entries.length === 0 ? (
<EmptyState title={copy.emptyTitle} body={copy.emptyBody}>
{tab === "from-the-network" ? (
<Button
as={Link}
to={sectionPath("contact", locale)}
variant="secondary"
size="sm"
>
{t("actions.submitYourUpdate")}
</Button>
) : null}
</EmptyState>
) : (
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
{entries.map((entry) => (
<Card.Content
key={entry.id}
as="li"
href={`${sectionPath("news", locale)}/${entry.slug}`}
title={entry.title}
actionLabel={t("actions.readMore")}
eyebrow={
entry.organisation
? t("news.postedBy", { organisation: entry.organisation })
: entry.dateLabel
}
{...(entry.excerpt ? { excerpt: entry.excerpt } : {})}
{...(entry.dateLabel && entry.organisation
? { badge: entry.dateLabel }
: {})}
{...(entry.image ? { image: entry.image } : {})}
/>
))}
</ul>
)}
<Pagination model={pagination} pathname={pathname} params={params} t={t} />
</section>
{/* "Are you a partner organisation? Submit your update ->" - §5 of the
draft. It points at Contact, which is where a submission is handled
until the CMS grows a submission form. */}
<Card.CTA
eyebrow={t("news.tabNetwork")}
headline={t("news.partnerPrompt")}
body={copy.networkBlurb}
action={{
href: sectionPath("contact", locale),
label: t("actions.submitYourUpdate"),
}}
<>
<ListingHero
eyebrow={chrome.eyebrow}
heading={copy.heading}
intro={copy.intro}
ambient={[{ top: -250, from: "start", offset: "8%", width: 720, height: 560, durationSeconds: 20 }]}
/>
</PageShell>
<PageShell>
<section className="flex flex-col gap-lg" aria-label={copy.heading}>
<TabLinks label={t("nav.sections.news")} tabs={tabs} />
<p className="body-md max-w-[var(--layout-prose-max)] text-text-secondary">
{tab === "from-the-network" ? copy.networkBlurb : copy.projectBlurb}
</p>
<ResultSummary pagination={pagination} t={t} />
{entries.length === 0 ? (
<EmptyState title={copy.emptyTitle} body={copy.emptyBody}>
{tab === "from-the-network" ? (
<Button
as={Link}
to={sectionPath("contact", locale)}
variant="secondary"
size="sm"
>
{t("actions.submitYourUpdate")}
</Button>
) : null}
</EmptyState>
) : (
<>
{featured ? (
<>
<h2 className="sr-only">{chrome.featuredLabel}</h2>
<FeaturedNewsCard
entry={featured}
href={hrefOf(featured)}
actionLabel={chrome.readFull}
/>
</>
) : null}
{grid.length > 0 ? (
<>
<h2 className="sr-only">{chrome.moreLabel}</h2>
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
{grid.map((entry) => (
<NewsCard
key={entry.id}
entry={entry}
href={hrefOf(entry)}
creditLabel={
entry.organisation
? t("news.postedBy", {
organisation: entry.organisation,
})
: undefined
}
/>
))}
</ul>
</>
) : null}
</>
)}
<Pagination model={pagination} pathname={pathname} params={params} t={t} />
</section>
{/* "Are you a partner organisation? Submit your update ->" - §5 of the
draft. It points at Contact, which is where a submission is handled
until the CMS grows a submission form. */}
<CalloutBand
eyebrow={t("news.tabNetwork")}
headline={t("news.partnerPrompt")}
body={copy.networkBlurb}
action={{
to: sectionPath("contact", locale),
label: t("actions.submitYourUpdate"),
}}
/>
</PageShell>
</>
);
}
/* ---------------------------------------------------------------------------
THE TWO CARDS
Composed from the exported card primitives rather than Card.Content: the
draft's newsroom cards carry a chip-and-date row above the headline and a
credit line pinned to the foot, neither of which that card has a slot for.
------------------------------------------------------------------------ */
/** The chip-and-date row. Both halves are optional and both come from Strapi. */
function NewsMetaRow({ entry }: { entry: NewsListItem }) {
if (!entry.tag && !entry.dateLabel) return null;
return (
<div className="flex flex-wrap items-center gap-sm">
{entry.tag ? (
<span className="rounded-[var(--radius-control)] bg-surface-accent-subtle px-xs py-2xs label-md uppercase text-text-accent">
{entry.tag}
</span>
) : null}
{entry.dateLabel ? (
<time
dateTime={toIsoDate(entry.date)}
className="label-md text-text-tertiary"
>
{entry.dateLabel}
</time>
) : null}
</div>
);
}
/** The wide lead card: picture beside the story, stacked on a phone. */
function FeaturedNewsCard({
entry,
href,
actionLabel,
}: {
entry: NewsListItem;
href: string;
actionLabel: string;
}) {
return (
<CardShell
linked
className="sh-reveal sh-reveal--in flex-col overflow-hidden md:flex-row"
>
{entry.image ? (
<div className="relative md:w-1/2 md:shrink-0">
<CardMedia image={entry.image} className="aspect-[16/10] w-full md:h-full" />
{/* The highlight travelling across the picture - the draft's sheen. */}
<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>
) : null}
<div className="flex flex-1 flex-col justify-center gap-md p-xl">
<NewsMetaRow entry={entry} />
<h3 className="sh-card-title heading-h3 text-text-primary">
<CardLink href={href} className="sh-card-link">
{entry.title}
</CardLink>
</h3>
{entry.excerpt ? (
<p className="body-md text-text-secondary">{entry.excerpt}</p>
) : null}
<CardActionHint label={actionLabel} />
</div>
</CardShell>
);
}
/** One card of the three-up grid. */
function NewsCard({
entry,
href,
creditLabel,
}: {
entry: NewsListItem;
href: string;
creditLabel?: string;
}) {
return (
<CardShell
as="li"
linked
className="sh-reveal sh-reveal--in flex-col overflow-hidden"
>
{entry.image ? (
<CardMedia image={entry.image} className="aspect-[9/5] w-full" />
) : null}
<div className="flex flex-1 flex-col gap-md p-lg">
<div className="flex flex-col gap-xs">
<NewsMetaRow entry={entry} />
<h3 className="sh-card-title body-lg-bold text-text-primary">
<CardLink href={href} className="sh-card-link">
{entry.title}
</CardLink>
</h3>
{entry.excerpt ? (
<p className="body-sm text-text-secondary line-clamp-3">
{entry.excerpt}
</p>
) : null}
</div>
{/* The draft closes the small card with the crediting byline and
nothing else - the read-more affordance belongs to the lead card
only, and the headline is already the link. */}
{creditLabel ? (
<p className="mt-auto label-md uppercase text-text-tertiary">
{creditLabel}
</p>
) : null}
</div>
</CardShell>
);
}
+269 -141
View File
@@ -3,9 +3,23 @@
----------------------------------------------------------------------------
/{locale}/resources-and-publications
§6 of docs/SHARPER_Website_Draft_Section_EN_AR.docx, with the four filters
the ToR mandates - topic, date, author, partner organisation - plus the
type tabs the draft lists and a sort order.
§6 of docs/SHARPER_Website_Draft_Section_EN_AR.docx with the four filters
the ToR mandates - topic, date, author, partner organisation - plus the type
categories the draft lists, a sort order and a free-text search.
Laid out to the 2026 redesign draft ("Resources-and-Publications"): a sticky
facet rail on the inline-start side, and a column of wide result rows, each
with its format chip, its metadata line, its language chip and a direct
download.
THE FACETS ARE LINKS, NOT CHECKBOXES
The draft draws them as checkboxes, which promises multi-select; the Strapi
query behind this listing takes one value per facet, so each option is a
link that sets the facet and clicking the active one clears it. Every view
is therefore still an ordinary URL - ?topic=access-to-justice&year=2026 -
that can be shared, bookmarked, walked back and followed by a crawler, and
it renders identically on the server and the client because nothing about it
is component state.
THE AUTHOR FILTER IS NOT A TAXONOMY
`resource.authors` is a plain STRING field in the CMS - there is no author
@@ -13,17 +27,15 @@
distinct values the published resources actually carry, which is also why
the filter offers nothing at all while the CMS is empty instead of 404ing.
FILTER STATE LIVES IN THE URL
Every control is a field of one `<form method="get">`, so a filtered view is
an ordinary link: ?topic=access-to-justice&year=2026&sort=oldest. It can be
shared, bookmarked, walked back with the browser's Back button and followed
by a crawler, and it renders identically on the server and the client
because nothing about it is component state.
NOTHING THE DRAFT LISTS IS PUBLISHED HERE. Its six sample publications -
the "Nationwide Perception Study", the "SHARP I and II Closing Report",
their page counts and their file sizes - were mockup content. Every row is
a Strapi entry; an empty CMS renders the localised empty state.
The canonical URL and the en/ar/x-default hreflang set are emitted for this
route by the app shell (root.tsx `Layout`) from the pathname, which is why
`buildMeta` is called WITHOUT `alternates` here: a leaf `meta` replaces its
parents' wholesale, and passing it would emit each link twice. Every filter
route by the app shell (root.tsx `Layout`), which is why `buildMeta` is
called WITHOUT `alternates` here: a leaf `meta` replaces its parents'
wholesale, and passing it would emit each link twice. Every filter
combination therefore canonicalises to the unfiltered listing, which is the
behaviour we want from a facet.
========================================================================= */
@@ -34,7 +46,6 @@ import {
CardActionHint,
CardEyebrow,
CardLink,
CardMedia,
CardShell,
} from "~/components/ui/Card";
import { Button } from "~/components/ui/Button";
@@ -42,6 +53,7 @@ import {
formatDate,
formatFileSize,
sectionPath,
toIsoDate,
translate,
useTranslation,
type Locale,
@@ -59,9 +71,7 @@ import {
fetchYearOptions,
taxonomyNames,
toFile,
toImage,
type CmsFile,
type CmsImage,
type ResourceEntry,
type ResourceType,
} from "./_shared/cms";
@@ -73,6 +83,7 @@ import {
parseExactText,
parseOptionalEnum,
parsePage,
parseQuery,
parseSlug,
parseSort,
parseYear,
@@ -80,6 +91,7 @@ import {
SORT_ORDERS,
requireLocale,
sortFor,
withParam,
yearFilter,
yearOptions,
type SelectOption,
@@ -87,15 +99,23 @@ import {
} from "./_shared/listing";
import {
EmptyState,
FilterBar,
FilterSelect,
FacetGroup,
FacetRail,
ListingHero,
MetaLine,
PageHeader,
PageShell,
Pagination,
ResultSummary,
SearchForm,
type FacetOption,
} from "./_shared/listing-ui";
import { RESOURCES_COPY } from "./_shared/page-copy";
import {
RESOURCES_COPY,
getListingChrome,
resourceLanguageLabel,
} from "./_shared/page-copy";
import "./_page-kit/motion.css";
/**
* The four publication kinds, as `resource.resourceType` publishes them,
@@ -132,9 +152,10 @@ interface ResourceListItem {
readonly authors?: string;
readonly partners: readonly string[];
readonly topics: readonly string[];
readonly image?: CmsImage;
readonly file?: CmsFile;
readonly fileLabel?: string;
readonly fileKind?: string;
readonly fileSizeLabel?: string;
readonly languageLabel?: string;
readonly externalUrl?: string;
}
@@ -153,6 +174,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const partner = parseSlug(search, "partner");
const year = parseYear(search);
const sort = parseSort(search);
const query = parseQuery(search);
const filters = compactFilters({
resourceType: type ? { $eq: type } : undefined,
@@ -161,6 +183,16 @@ export async function loader({ params, request }: Route.LoaderArgs) {
authors: author ? { $eq: author } : undefined,
partnerOrganisations: partner ? { slug: { $eq: partner } } : undefined,
[DATE_FIELD]: year ? yearFilter(year) : undefined,
/* The draft's "Title, author, keyword" box. The body of a document is not
searched - it is a file, not a field - so the three things a reader can
actually see on the card are what the query hits. */
$or: query
? [
{ title: { $containsi: query } },
{ description: { $containsi: query } },
{ authors: { $containsi: query } },
]
: undefined,
});
// One round trip per facet, all in flight together: the listing itself, the
@@ -172,9 +204,8 @@ export async function loader({ params, request }: Route.LoaderArgs) {
filters,
sort: sortFor(DATE_FIELD, sort),
populate: {
thumbnail: {
fields: ["url", "alternativeText", "width", "height", "formats"],
},
// No thumbnail: the listing row draws the format chip instead of a
// cover, so the image is fetched by the detail route, not here.
file: { fields: ["url", "name", "ext", "mime", "size"] },
partnerOrganisations: { fields: ["name", "slug"] },
topics: { fields: ["name", "slug"] },
@@ -202,6 +233,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
partner: partner ?? "",
year: year === undefined ? "" : String(year),
sort,
q: query ?? "",
},
facets: {
types: enumOptions(RESOURCE_TYPES, TYPE_LABEL, locale),
@@ -216,9 +248,9 @@ export async function loader({ params, request }: Route.LoaderArgs) {
function toListItem(entry: ResourceEntry, locale: Locale): ResourceListItem {
const file = toFile(entry.file);
const image = toImage(entry.thumbnail, "small");
const date = entry.publicationDate ?? entry.publishedAt ?? undefined;
const authors = entry.authors?.trim();
const language = resourceLanguageLabel(entry.resourceLanguage, locale);
return {
id: entry.documentId,
@@ -232,13 +264,14 @@ function toListItem(entry: ResourceEntry, locale: Locale): ResourceListItem {
...(authors ? { authors } : {}),
partners: taxonomyNames(entry.partnerOrganisations),
topics: taxonomyNames(entry.topics),
...(image ? { image } : {}),
...(file
? {
file,
fileLabel: `${file.kind} · ${formatFileSize(file.sizeBytes, locale)}`,
fileKind: file.kind,
fileSizeLabel: formatFileSize(file.sizeBytes, locale),
}
: {}),
...(language ? { languageLabel: language } : {}),
...(entry.externalUrl ? { externalUrl: entry.externalUrl } : {}),
};
}
@@ -281,108 +314,176 @@ export default function ResourcesAndPublications({
const params = new URLSearchParams(search);
const copy = RESOURCES_COPY[locale];
const chrome = getListingChrome(locale).resources;
const sortLabel =
facets.sorts.find((option) => option.value === filters.sort)?.label ?? "";
const isFiltered =
Boolean(filters.type) ||
Boolean(filters.topic) ||
Boolean(filters.author) ||
Boolean(filters.partner) ||
Boolean(filters.year);
Boolean(filters.year) ||
Boolean(filters.q);
/** One facet option: sets the value, or clears it when it is already on. */
const facetOptions = (
key: string,
current: string,
options: readonly SelectOption[],
): FacetOption[] =>
options.map((option) => {
const isCurrent = current === option.value;
return {
value: option.value,
label: option.label,
href: `${pathname}${withParam(
params,
key,
isCurrent ? undefined : option.value,
)}`,
isCurrent,
};
});
/* Everything except the search term, so submitting the box keeps the facets
the reader has already set. */
const keep = [...params.entries()]
.filter(([key]) => key !== "q" && key !== "page")
.map(([key, value]) => [key, value] as const);
return (
<PageShell>
<PageHeader heading={copy.heading} intro={copy.intro} />
<>
<ListingHero
eyebrow={chrome.eyebrow}
heading={copy.heading}
intro={copy.intro}
ambient={[{ top: -250, from: "end", offset: "6%", width: 720, height: 560, durationSeconds: 22 }]}
/>
<section className="flex flex-col gap-lg" aria-label={copy.heading}>
<FilterBar
legend={t("filters.heading")}
action={pathname}
applyLabel={t("filters.apply")}
clearLabel={t("filters.clear")}
clearHref={pathname}
showClear={isFiltered || filters.sort !== "newest"}
>
<FilterSelect
name="type"
label={t("filters.category")}
value={filters.type}
options={facets.types}
allLabel={t("filters.all")}
/>
<FilterSelect
name="topic"
label={t("filters.topic")}
value={filters.topic}
options={facets.topics}
allLabel={t("filters.all")}
/>
<FilterSelect
name="author"
label={t("filters.author")}
value={filters.author}
options={facets.authors}
allLabel={t("filters.all")}
/>
<FilterSelect
name="partner"
label={t("filters.partner")}
value={filters.partner}
options={facets.partners}
allLabel={t("filters.all")}
/>
<FilterSelect
name="year"
label={t("filters.date")}
value={filters.year}
options={facets.years}
allLabel={t("filters.all")}
/>
{/* No "All" option: sorting always has a value. */}
<FilterSelect
name="sort"
label={t("filters.sortBy")}
value={filters.sort}
options={facets.sorts}
/>
</FilterBar>
<PageShell>
<div className="grid gap-2xl lg:grid-cols-[minmax(0,17rem)_minmax(0,1fr)]">
<FacetRail label={chrome.facetsLabel}>
<SearchForm
action={pathname}
label={t("search.label")}
placeholder={chrome.searchPlaceholder}
defaultValue={filters.q}
submitLabel={t("search.submit")}
keep={keep}
className="flex-col items-stretch"
/>
<ResultSummary pagination={pagination} t={t} />
<FacetGroup
heading={t("filters.category")}
options={facetOptions("type", filters.type, facets.types)}
/>
<FacetGroup
heading={t("filters.topic")}
options={facetOptions("topic", filters.topic, facets.topics)}
/>
<FacetGroup
heading={t("filters.author")}
options={facetOptions("author", filters.author, facets.authors)}
/>
<FacetGroup
heading={t("filters.partner")}
options={facetOptions("partner", filters.partner, facets.partners)}
/>
<FacetGroup
heading={t("filters.date")}
options={facetOptions("year", filters.year, facets.years)}
/>
{/* Sorting always has a value, so its options never clear. */}
<FacetGroup
heading={t("filters.sortBy")}
options={facets.sorts.map((option) => ({
value: option.value,
label: option.label,
href: `${pathname}${withParam(params, "sort", option.value)}`,
isCurrent: filters.sort === option.value,
}))}
/>
{entries.length === 0 ? (
<EmptyState
title={isFiltered ? t("filters.noResults") : copy.emptyTitle}
body={isFiltered ? t("filters.noResultsHint") : copy.emptyBody}
>
{isFiltered ? (
<Button as={Link} to={pathname} variant="secondary" size="sm">
{isFiltered || filters.sort !== "newest" ? (
<Button
as={Link}
to={pathname}
variant="secondary"
size="sm"
className="self-start"
>
{t("filters.clear")}
</Button>
) : null}
</EmptyState>
) : (
<ul className="flex flex-col gap-lg">
{entries.map((entry) => (
<ResourceRow
key={entry.id}
entry={entry}
href={`${sectionPath("resources", locale)}/${entry.slug}`}
readLabel={t("actions.viewDetails")}
downloadLabel={t("actions.download")}
/>
))}
</ul>
)}
</FacetRail>
<Pagination model={pagination} pathname={pathname} params={params} t={t} />
</section>
</PageShell>
<section className="flex min-w-0 flex-col gap-lg" aria-label={copy.heading}>
<div className="flex flex-wrap items-baseline justify-between gap-md border-b border-border-subtle pb-md">
<div className="flex flex-wrap items-baseline gap-md">
<ResultSummary pagination={pagination} t={t} />
{filters.q ? (
<p className="body-sm text-text-secondary">
{t("search.resultsFor", { query: filters.q })}
</p>
) : null}
</div>
{/* "Sorted by date · newest first" in the draft. The order in
force is read back from the query string, never asserted. */}
<p className="label-md uppercase text-text-tertiary">
{t("filters.sortBy")}
<span aria-hidden="true"> · </span>
{sortLabel}
</p>
</div>
{entries.length === 0 ? (
<EmptyState
title={isFiltered ? t("filters.noResults") : copy.emptyTitle}
body={isFiltered ? t("filters.noResultsHint") : copy.emptyBody}
>
{isFiltered ? (
<Button as={Link} to={pathname} variant="secondary" size="sm">
{t("filters.clear")}
</Button>
) : null}
</EmptyState>
) : (
<ul className="flex flex-col gap-md">
{entries.map((entry) => (
<ResourceRow
key={entry.id}
entry={entry}
href={`${sectionPath("resources", locale)}/${entry.slug}`}
readLabel={t("actions.viewDetails")}
downloadLabel={t("actions.download")}
/>
))}
</ul>
)}
<Pagination
model={pagination}
pathname={pathname}
params={params}
t={t}
/>
</section>
</div>
</PageShell>
</>
);
}
/* ---------------------------------------------------------------------------
ONE RESULT
Composed from the exported card primitives rather than Card.Horizontal:
a publication carries a metadata line (date · author · partner · PDF · size)
and a direct download, neither of which that card has a slot for.
a publication carries a metadata line (date · author · partner), a format
chip, a language chip and a direct download, none of which that card has a
slot for.
------------------------------------------------------------------------ */
function ResourceRow({
@@ -399,40 +500,67 @@ function ResourceRow({
const downloadHref = entry.file?.url ?? entry.externalUrl;
return (
<CardShell as="li" linked className="flex-col overflow-hidden sm:flex-row">
{entry.image ? (
<CardMedia
image={entry.image}
className="aspect-[6/5] w-full sm:aspect-auto sm:w-60 sm:shrink-0"
/>
) : null}
<div className="flex flex-1 flex-col gap-md p-lg">
<div className="flex flex-col gap-xs">
{entry.typeLabel ? <CardEyebrow>{entry.typeLabel}</CardEyebrow> : null}
<h2 className="sh-card-title body-lg-bold text-text-primary">
<CardLink href={href} className="sh-card-link">
{entry.title}
</CardLink>
</h2>
<MetaLine
items={[
entry.dateLabel ?? "",
entry.authors ?? "",
entry.partners.join(", "),
entry.fileLabel ?? "",
]}
/>
{entry.summary ? (
<p className="body-sm text-text-secondary line-clamp-3">{entry.summary}</p>
<CardShell
as="li"
linked
className="sh-reveal sh-reveal--in flex-col overflow-hidden sm:flex-row"
>
{/* No thumbnail: the draft anchors the row on the format chip below and
keeps every result the same height, whether or not the CMS entry
carries a cover. The thumbnail still renders on the detail page. */}
<div className="flex flex-1 flex-col gap-md p-lg lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 gap-md">
{/* The draft's document chip. Only drawn for a file we actually
hold - an externally hosted document has no format to name. */}
{entry.fileKind ? (
<span
aria-hidden="true"
className="flex h-14 w-11 shrink-0 items-center justify-center rounded-[var(--radius-control)] border border-border-default bg-surface-sunken label-md text-text-accent"
>
{entry.fileKind}
</span>
) : null}
<div className="flex min-w-0 flex-col gap-xs">
{entry.typeLabel ? <CardEyebrow>{entry.typeLabel}</CardEyebrow> : null}
<h2 className="sh-card-title body-lg-bold text-text-primary">
<CardLink href={href} className="sh-card-link">
{entry.title}
</CardLink>
</h2>
<MetaLine
items={[
entry.authors ?? "",
entry.dateLabel ?? "",
entry.fileSizeLabel ?? "",
entry.partners.join(", "),
]}
/>
{entry.date ? (
<time dateTime={toIsoDate(entry.date)} className="sr-only">
{entry.dateLabel}
</time>
) : null}
{entry.summary ? (
<p className="body-sm text-text-secondary line-clamp-3">
{entry.summary}
</p>
) : null}
<CardActionHint label={readLabel} />
</div>
</div>
<div className="flex flex-wrap items-center gap-md">
<CardActionHint label={readLabel} />
<div className="flex shrink-0 flex-wrap items-center gap-md">
{entry.languageLabel ? (
<span className="rounded-pill border border-border-default px-md py-2xs label-md uppercase text-text-primary">
{entry.languageLabel}
</span>
) : null}
{downloadHref ? (
// `sh-card-above` lifts it over the stretched title link, so the
@@ -440,7 +568,7 @@ function ResourceRow({
<Button
as="a"
href={downloadHref}
variant="secondary"
variant="primary"
size="sm"
className="sh-card-above"
{...(entry.file
+141 -53
View File
@@ -1,15 +1,36 @@
/* ============================================================================
WHAT WE DO - /en/what-we-do and /ar/what-we-do
----------------------------------------------------------------------------
Section 2 of docs/SHARPER_Website_Draft_Section_EN_AR.docx: the participatory
approach, the four connected areas of work, the values that guide them and
the four principles the approach rests on.
The redesigned What We Do page ("What-We-Do", Claude Design draft), carried
onto the project's own design system. Four bands:
Hero eyebrow, headline and the one-line lead, on the sunken
surface with the two drifting washes behind it.
Areas the four connected areas of work as a seam grid, each cell
led by its numeral.
Guidance the five values as a centred chip row, then the four
principles as bar-topped tiles.
Activities the closing panel with the accent start-edge.
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
--------------------------------------------
* The draft gives each principle an explanatory line ("Local organisations
lead the design and delivery of activities", "Solutions are built to
outlast the project's own timeline", "Institutional change is supported
step by step, not imposed"). None of those sentences is in an approved
document, so the tiles carry only the clause the approved "How we work"
sentence states, which is what the draft's own titles carry too.
* The draft's coloured hover glow is deliberately dropped.
Overridden field by field by api::what-we-do-page.what-we-do-page; the draft
copy below is what renders until an editor fills that in.
Arabic is this same DOM under `dir="rtl"`, so every inline edge is logical.
========================================================================= */
import { Card } from "~/components/ui/Card";
import { Link } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { createTranslator, formatNumber, sectionPath } from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
@@ -25,7 +46,6 @@ import { pageCopy } from "./_page-kit/copy";
import { routeLocale } from "./_page-kit/locale";
import { RichText } from "./_page-kit/rich-text";
import {
InfoPanel,
PageHero,
PageSection,
Prose,
@@ -33,6 +53,8 @@ import {
ValueChips,
} from "./_page-kit/sections";
import "./_page-kit/motion.css";
export async function loader({ params }: Route.LoaderArgs) {
const locale = routeLocale(params);
@@ -104,6 +126,10 @@ export function meta({ loaderData, matches }: Route.MetaArgs) {
});
}
/** See the note on the same constant in about.tsx. */
const ACCENT_EDGE =
"[border-inline-start-color:var(--border-accent)] [border-inline-start-width:4px]";
export default function WhatWeDo({ loaderData }: Route.ComponentProps) {
const { locale, hero, intro, richTextBlocks } = loaderData;
const t = createTranslator(locale);
@@ -117,25 +143,40 @@ export default function WhatWeDo({ loaderData }: Route.ComponentProps) {
const values =
loaderData.guidingValues.length > 0 ? loaderData.guidingValues : copy.values;
/* An editor's principles carry a line of their own; the bundled fallback is
the four clauses of the approved sentence and nothing more. */
const principles =
loaderData.principles.length > 0
? loaderData.principles
: copy.principleTitles.map((title) => ({ title, description: "" }));
return (
<>
<PageHero
className="border-b border-[var(--border-subtle)] bg-[var(--surface-sunken)] [--layout-hero-bottom:clamp(2.75rem,6vw,4.25rem)]"
title={hero.headline ?? copy.heroHeadline}
eyebrow={hero.eyebrow ?? t("nav.sections.aboutAndStrategy")}
eyebrow={hero.eyebrow ?? t("nav.sections.whatWeDo")}
lead={hero.subheading ?? copy.lead}
ambient={[{ top: -240, from: "end", offset: "-120px", width: 700, height: 560, durationSeconds: 22 }]}
leadTone="primary"
reveal
{...(hero.media ? { media: hero.media } : {})}
/>
{/* THE FOUR AREAS OF WORK */}
{/* THE FOUR AREAS OF WORK - one seam grid, the cells raised out of the
band, each led by its numeral (localised, so the Arabic page numbers
its cells in its own conventions). */}
<PageSection
aria-labelledby="wwd-workstreams-heading"
className="pt-0 md:pt-0"
className="bg-[var(--surface-sunken)]"
>
<div className="flex flex-col gap-2xl">
{/* The draft prints the heading alone - the approved intro sentence
IS this heading - so no lead is drawn under it. */}
<SectionHeader
className="sh-reveal max-w-[var(--layout-prose-max)]"
id="wwd-workstreams-heading"
title={copy.workstreamsHeading}
lead={intro ? undefined : copy.workstreamsIntro}
/>
{intro ? (
@@ -144,68 +185,115 @@ export default function WhatWeDo({ loaderData }: Route.ComponentProps) {
</Prose>
) : null}
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-2">
<ul className="grid list-none grid-cols-1 gap-[1px] overflow-hidden rounded-card border border-[var(--border-subtle)] bg-[var(--border-subtle)] sm:grid-cols-2 lg:grid-cols-4">
{workstreams.map((workstream, index) => (
<li key={workstream.title}>
<InfoPanel
title={workstream.title}
eyebrow={formatNumber(index + 1, locale, {
minimumIntegerDigits: 2,
})}
<li
key={workstream.title}
className="sh-reveal flex min-h-[16.25rem] flex-col gap-sm bg-[var(--surface-card)] p-lg transition-colors duration-[var(--motion-duration-base)] hover:bg-[var(--surface-muted)]"
>
<p
aria-hidden="true"
className="heading-h2 text-[var(--text-accent)]"
>
{formatNumber(index + 1, locale, { minimumIntegerDigits: 2 })}
</p>
{/* The draft sets an area title a step above body - the same
size it gives the consortium names on About. */}
<h3 className="heading-h3 text-[var(--text-primary)]">
{workstream.title}
</h3>
<p className="body-md text-[var(--text-secondary)]">
{workstream.description}
</InfoPanel>
</p>
</li>
))}
</ul>
<ValueChips values={values} label={copy.valuesLabel} />
</div>
</PageSection>
{/* HOW WE WORK */}
<PageSection aria-labelledby="wwd-principles-heading">
<div className="flex flex-col gap-lg">
<SectionHeader
id="wwd-principles-heading"
title={copy.principlesHeading}
{/* WHAT GUIDES US, AND HOW WE WORK - the one band the draft raises off
the near-black ground, so it names its surface explicitly. */}
<PageSection
aria-labelledby="wwd-principles-heading"
band
>
<div className="flex flex-col gap-2xl">
{/* The draft's chips rest as a plain outline and only take the accent
fill under the pointer; the shared component rests filled, so the
resting and hover paints are restated here until it carries the
draft's own treatment. */}
<ValueChips
className="sh-reveal"
align="center"
values={values}
label={copy.valuesLabel}
/>
{loaderData.principles.length > 0 ? (
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-2">
{loaderData.principles.map((principle) => (
<li key={principle.title}>
<InfoPanel title={principle.title}>
{principle.description}
</InfoPanel>
<div className="flex flex-col gap-2xl border-t border-[var(--border-default)] pt-2xl">
{/* The draft draws the four principles straight under the rule,
with no heading over them; the heading stays in the outline. */}
<h2 id="wwd-principles-heading" className="sr-only">
{copy.principlesHeading}
</h2>
<ul className="grid list-none grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-4">
{principles.map((principle) => (
<li
key={principle.title}
className="sh-reveal flex flex-col gap-sm"
>
{/* The short accent rule the draft draws over each tile,
drawn on from the inline start. Decorative. */}
<span
aria-hidden="true"
className="sh-bar-grow block h-[3px] w-[44px] bg-[var(--surface-accent)]"
/>
<h3 className="body-md-bold text-[var(--text-primary)]">
{principle.title}
</h3>
{principle.description ? (
<p className="body-sm text-[var(--text-secondary)]">
{principle.description}
</p>
) : null}
</li>
))}
</ul>
) : (
<Prose>
<p className="body-md text-[var(--text-secondary)]">
{copy.principles}
</p>
</Prose>
)}
{richTextBlocks.map((block) => (
<Prose key={block.id}>
<RichText markdown={block.body} />
</Prose>
))}
{richTextBlocks.map((block) => (
<Prose key={block.id}>
<RichText markdown={block.body} />
</Prose>
))}
</div>
</div>
</PageSection>
<PageSection>
<Card.CTA
headline={t("nav.sections.activities")}
body={copy.lead}
action={{
href: sectionPath("activities", locale),
label: t("actions.seeActivities"),
}}
/>
{/* THE WAY ON TO THE ACTIVITIES */}
<PageSection
aria-labelledby="wwd-activities-heading"
className="bg-[var(--surface-sunken)]"
>
<div
className={`sh-reveal flex flex-wrap items-center justify-between gap-xl rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-lg md:p-xl ${ACCENT_EDGE}`}
>
<h2
id="wwd-activities-heading"
className="heading-h2 max-w-[39rem] text-[var(--text-primary)]"
>
{copy.activitiesCtaHeading}
</h2>
<Button
as={Link}
to={sectionPath("activities", locale)}
prefetch="intent"
size="lg"
iconEnd={<ArrowIcon />}
>
{t("actions.seeActivities")}
</Button>
</div>
</PageSection>
</>
);
+2
View File
@@ -33,6 +33,7 @@
--color-neutral-600: #454543;
--color-neutral-700: #2e2e2c;
--color-neutral-800: #1d1d1b; /* [figma] Neutral Black/800 - brand Dark Grey */
--color-neutral-850: #141413; /* the design drafts' footer / raised panel */
--color-neutral-900: #0b0b0a; /* [figma] Neutral Black/900 */
/* --------------------------------------------------------------------
@@ -225,6 +226,7 @@
--duration-base: 200ms;
--duration-slow: 320ms;
--duration-slower: 520ms;
--duration-slowest: 800ms;
/* --------------------------------------------------------------------
EASING ramp
+28 -4
View File
@@ -28,7 +28,10 @@
/* --- surfaces / backgrounds ------------------------------------------ */
--surface-page: var(--color-neutral-800); /* figma background/primary */
--surface-raised: var(--color-neutral-700); /* cards sitting on the page */
/* The drafts draw a card on a band DARKER than the band (#141413 on
#1D1D1B), not lighter - so "raised" is a recessed panel here, and the
old neutral-700 step is not used by the dark palette at all. */
--surface-raised: var(--color-neutral-850); /* the drafts' card / panel */
--surface-sunken: var(--color-neutral-900); /* figma Neutral Black/900 */
--surface-muted: var(--color-white-a08); /* subtle fills, chips, wells */
--surface-accent: var(--color-green-600); /* figma background/accent */
@@ -36,7 +39,14 @@
--surface-danger: var(--color-red-500);
--surface-danger-subtle: var(--color-red-a24);
--surface-inverse: var(--color-white); /* figma background/inverse */
--surface-footer: var(--color-black); /* figma base/black - the footer sits below the page, not on it */
--surface-footer: var(--color-neutral-850); /* the drafts' #141413 - the footer sits below the page, not on it */
/* The drafts float the bar over the page: the ground colour at 90%, with a
blur behind it, so content shows through as it scrolls under. */
--surface-nav: color-mix(in srgb, var(--color-neutral-900) 90%, transparent);
/* A card is one step off whatever it stands on: #1D1D1B on the #0B0B0A
ground, #141413 inside a #1D1D1B band. Components paint
`--surface-card`; a band re-points it for its own subtree (.sh-band). */
--surface-card: var(--surface-page);
--surface-selection: var(--color-green-a40);
/* --- text ------------------------------------------------------------- */
@@ -80,7 +90,9 @@
--interactive-secondary-fg: var(--color-neutral-50);
--interactive-secondary-fg-hover: var(--color-neutral-50);
--interactive-secondary-fg-disabled: var(--color-neutral-500);
--interactive-secondary-border: var(--color-neutral-50);
/* The redesign's outline button: a mid-grey ring at rest that brightens to
white under the pointer, rather than a full-white ring throughout. */
--interactive-secondary-border: var(--color-neutral-400);
--interactive-secondary-border-hover: var(--color-neutral-50);
--interactive-secondary-border-disabled: var(--color-white-a24);
@@ -127,7 +139,9 @@
--interactive-ghost-border-disabled: var(--color-transparent);
/* --- form fields --------------------------------------------------------- */
--field-bg: var(--color-white-a08);
/* The drafts sink an input into its panel (#0B0B0A inside #1D1D1B) rather
than lifting it with a translucent white. */
--field-bg: var(--color-neutral-900);
--field-bg-disabled: var(--color-white-a04);
--field-fg: var(--color-neutral-50);
--field-fg-disabled: var(--color-neutral-500);
@@ -163,6 +177,7 @@
/* --- surfaces / backgrounds ------------------------------------------ */
--surface-page: var(--color-white); /* figma background/primary (light) */
--surface-raised: var(--color-white);
--surface-card: var(--surface-raised);
--surface-sunken: var(--color-neutral-50);
--surface-muted: var(--color-neutral-100); /* figma stroke/subtle as a fill */
--surface-accent: var(--color-green-600);
@@ -322,6 +337,13 @@
--layout-gutter: var(--spacing-lg);
--layout-gutter-lg: var(--spacing-xl);
--layout-section-gap: var(--spacing-3xl);
/* Hero block padding. The drafts open a hero at 52px and grow it to 92px on
a wide screen, closing it at 44 -> 68. Kept as tokens because the hero is
what the drifting wash is clipped by: a short hero crops the glow. */
--layout-hero-top: clamp(3.25rem, 7vw, 5.75rem);
--layout-hero-bottom: clamp(2.75rem, 6vw, 4.25rem);
/* Clearance under the sticky bar for a sticky column (the drafts' 100px). */
--layout-sticky-top: 6.25rem;
--layout-stack-gap: var(--spacing-md);
--layout-content-max: var(--container-content);
--layout-prose-max: var(--container-prose);
@@ -341,6 +363,8 @@
--motion-duration-fast: var(--duration-fast);
--motion-duration-base: var(--duration-base);
--motion-duration-slow: var(--duration-slow);
--motion-duration-reveal: var(--duration-slowest); /* 800ms - one-off brand reveals */
--motion-delay-reveal: var(--duration-slow); /* 320ms - lets first paint settle */
--motion-ease-standard: var(--ease-standard);
--motion-ease-entrance: var(--ease-decelerate);
--motion-ease-exit: var(--ease-accelerate);
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"dev": "react-router dev --host",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
},
@@ -0,0 +1,10 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="globe" clip-path="url(#clip0_0_123)">
<path id="Vector" d="M12 0C9.62663 0 7.30655 0.703788 5.33316 2.02236C3.35977 3.34094 1.8217 5.21509 0.913451 7.4078C0.00519943 9.60051 -0.232441 12.0133 0.230582 14.3411C0.693605 16.6689 1.83649 18.8071 3.51472 20.4853C5.19295 22.1635 7.33115 23.3064 9.65892 23.7694C11.9867 24.2324 14.3995 23.9948 16.5922 23.0866C18.7849 22.1783 20.6591 20.6402 21.9776 18.6668C23.2962 16.6935 24 14.3734 24 12C23.9966 8.81846 22.7312 5.76821 20.4815 3.51852C18.2318 1.26883 15.1815 0.00344108 12 0ZM15.222 7H8.77801C9.62677 5.32427 10.7119 3.77916 12 2.412C13.2886 3.77877 14.3738 5.32396 15.222 7ZM16.022 9C16.3294 9.97068 16.4898 10.9819 16.498 12C16.4898 13.0181 16.3294 14.0293 16.022 15H7.97601C7.66866 14.0293 7.50821 13.0181 7.50001 12C7.50821 10.9819 7.66866 9.97068 7.97601 9H16.022ZM9.40001 2.356C8.24766 3.76578 7.29659 5.3287 6.57401 7H3.35301C4.67886 4.71643 6.85166 3.04775 9.40001 2.356ZM2.00001 12C1.99991 10.9824 2.15538 9.97067 2.46101 9H5.90001C5.64076 9.97915 5.50636 10.9871 5.50001 12C5.50636 13.0129 5.64076 14.0209 5.90001 15H2.46101C2.15538 14.0293 1.99991 13.0176 2.00001 12ZM3.35301 17H6.57401C7.29659 18.6713 8.24766 20.2342 9.40001 21.644C6.85166 20.9522 4.67886 19.2836 3.35301 17ZM8.77801 17H15.222C14.3732 18.6757 13.2882 20.2208 12 21.588C10.7114 20.2212 9.62625 18.676 8.77801 17ZM14.605 21.644C15.7556 20.2339 16.705 18.671 17.426 17H20.647C19.3223 19.2825 17.1515 20.9511 14.605 21.644ZM22 12C22.0001 13.0176 21.8446 14.0293 21.539 15H18.1C18.3592 14.0209 18.4936 13.0129 18.5 12C18.4936 10.9871 18.3592 9.97915 18.1 9H21.537C21.8433 9.97056 21.9995 10.9823 22 12ZM17.426 7C16.705 5.32899 15.7556 3.76609 14.605 2.356C17.1515 3.04893 19.3223 4.71747 20.647 7H17.426Z" fill="#1D1D1B"/>
</g>
<defs>
<clipPath id="clip0_0_123">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="116.87" height="46" viewBox="217.133 0 116.867 46"><!-- "-ER" of the SHARP-ER lockup: dash, E and R from Figma logo/sharp-er (317:5571), in its 334x46 units. The viewBox starts at the P's right edge so this sits flush after sharp-wordmark-inverse.svg; glyphs are lowered 0.66 units onto the SHARP baseline. --><g fill="#FFFFFF" transform="translate(0 0.655)"><rect x="222.411" y="18.612" width="25.518" height="7.181"/><path transform="translate(254.297 0.680)" d="M0 0H31.5681L32.2058 12.6272H31.0579C29.2722 8.33311 27.4015 5.39953 25.4458 3.82644C23.4901 2.21083 20.6203 1.40303 16.8363 1.40303H14.6042V21.1092H16.7725C18.7708 21.1092 20.4501 20.3652 21.8107 18.8771C23.1712 17.3465 24.1278 15.3058 24.6805 12.7548H25.6371V31.6319H24.553C23.7877 28.4432 22.746 26.1686 21.428 24.8081C20.1526 23.4475 18.3882 22.7673 16.1348 22.7673H14.6042V43.2387H17.0276C20.9391 43.2387 24.2554 42.0695 26.9764 39.7312C29.7399 37.3928 31.6319 34.3954 32.6523 30.739H33.9277L33.4175 44.6418H0V43.2387H3.58728V1.40303H0V0Z"/><path transform="translate(291.515 0.106)" d="M15.3596 2.32247C15.8358 1.61254 16.8895 1.25758 18.5207 1.25758C21.3575 1.25758 23.3535 2.17034 24.5186 3.98572C25.6837 5.80109 26.2714 8.35682 26.2714 11.6529C26.2714 13.5697 26.0282 15.3141 25.552 16.8759C25.0758 18.4377 24.1944 19.6953 22.9279 20.6284C21.6615 21.5614 19.7871 22.0381 17.3251 22.0381H14.6402V6.64286C14.6402 4.47252 14.8834 3.03239 15.3596 2.32247ZM41.5296 40.8206C41.1142 41.277 40.7292 41.6218 40.3746 41.8551C40.02 42.0883 39.5742 42.2202 39.0271 42.2608C38.2368 42.2608 37.629 41.9261 37.2135 41.2567C36.7982 40.5874 36.403 39.4211 36.0281 37.7477L34.5894 31.2975C34.0423 28.7114 32.8671 26.7743 31.0535 25.4761C29.2399 24.178 26.4639 23.3768 22.705 23.0421C24.3362 22.8596 25.8053 22.5756 27.0819 22.1902C29.5034 21.46 31.4081 20.4762 32.8063 19.2491C34.2045 18.0219 35.1872 16.6832 35.7749 15.2431C36.3625 13.8029 36.6563 12.373 36.6563 10.9531C36.6563 7.48463 35.2176 4.8072 32.3402 2.91069C29.4629 1.01418 25.1265 0.0608505 19.3312 0.0608505C18.5409 0.0608505 17.2035 0.0811341 15.3292 0.121701C13.4548 0.162268 11.5096 0.182552 9.51363 0.182552C7.76084 0.182552 5.99795 0.162268 4.22489 0.121701C2.45186 0.0811341 1.04357 0.0405669 0 0V1.25758C1.29686 1.29815 2.27962 1.46042 2.96857 1.7241C3.65753 1.98779 4.11345 2.49488 4.34648 3.22508C4.57949 3.95529 4.69095 5.09117 4.69095 6.63272V37.6868C4.69095 39.1878 4.57949 40.3135 4.34648 41.064C4.11345 41.8145 3.66766 42.3216 3.00909 42.565C2.35056 42.8084 1.33738 42.9808 0 43.062V44.3094C1.04357 44.2283 2.45186 44.1776 4.22489 44.1573C5.99795 44.137 7.88241 44.1268 9.88849 44.1268C11.6413 44.1268 13.3637 44.137 15.0759 44.1573C16.7881 44.1776 18.2066 44.2283 19.3312 44.3094V43.0721C17.9938 42.991 17.0009 42.8186 16.3322 42.5752C15.6635 42.3318 15.2177 41.8247 14.9847 41.0742C14.7517 40.3237 14.6402 39.1979 14.6402 37.697V23.2956H17.9533H18.703C19.9999 23.2956 21.0029 23.681 21.7324 24.4518C22.4619 25.2226 23.0292 26.2672 23.4548 27.5856C23.8803 28.904 24.2248 30.4355 24.5186 32.19L25.6432 38.4475C26.0991 41.0336 27.0616 42.8084 28.5206 43.7617C29.9796 44.7151 31.8336 45.2019 34.093 45.2019C35.9268 45.2019 37.4871 44.9179 38.784 44.3601C40.0808 43.8023 41.2865 42.8693 42.4111 41.5711L41.5296 40.8206Z"/></g></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB