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
@@ -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. */