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
+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>