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>
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
/* ============================================================================
|
|
Footer - figma frame 140:7813
|
|
----------------------------------------------------------------------------
|
|
ONE component for all four published variants:
|
|
light EN 140:7812 | dark EN 140:7814 | light AR 140:8713 | dark AR 140:8685
|
|
`surface` picks the palette and the logo cut; direction comes from `<html dir>`
|
|
and every offset is a logical property, so the Arabic variants are the same
|
|
markup mirrored - there is no RTL branch anywhere below.
|
|
|
|
STYLING: Tailwind utilities for layout and spacing, tier-2 semantic tokens for
|
|
everything that carries meaning (`text-[var(--text-primary)]`). No raw hex, no
|
|
tier-1 primitive, no component stylesheet.
|
|
|
|
ONE DELIBERATE DEVIATION FROM THE PUBLISHED FRAMES:
|
|
the two EN frames disagree on the sitemap. 140:7812 carries the real IA but
|
|
no column headings; 140:7814 carries headings but labels for pages that do
|
|
not exist (Partners, Blog, Photo Gallery, SUQs, Pilot Initiatives,
|
|
Research). This takes the newer IA - built from ROUTE_SLUGS, the same
|
|
source the router is built from - and the headings. Linking the frame's
|
|
labels verbatim is what previously shipped five dead links.
|
|
|
|
Column headings take `--text-tertiary`, as the frames do: that token is
|
|
already re-pointed to a readable step on the light surface (semantic.css),
|
|
so it needs no a11y override here.
|
|
========================================================================= */
|
|
|
|
import { Link } from "react-router";
|
|
|
|
import type {
|
|
FooterCopy,
|
|
FooterLink,
|
|
FooterPartner,
|
|
FooterProps,
|
|
FooterSocialLink,
|
|
} from "./types";
|
|
import {
|
|
DEFAULT_FOOTER_COPY,
|
|
DEFAULT_FOOTER_PARTNERS,
|
|
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 =>
|
|
parts.filter(Boolean).join(" ");
|
|
|
|
function definedOnly<T extends object>(source: Partial<T> | undefined): Partial<T> {
|
|
if (!source) return {};
|
|
return Object.fromEntries(
|
|
Object.entries(source).filter(([, value]) => value !== undefined),
|
|
) as Partial<T>;
|
|
}
|
|
|
|
/** Visible focus ring on every interactive element, straight off the focus tokens. */
|
|
const FOCUS_RING = cx(
|
|
"rounded-[var(--radius-control)]",
|
|
"focus-visible:[outline:var(--focus-ring-width)_solid_var(--focus-ring-color)]",
|
|
"focus-visible:[outline-offset:var(--focus-ring-offset)]",
|
|
);
|
|
|
|
/** Only `color` - `transition-colors` would also fade `outline-color`, which
|
|
makes the focus ring arrive late. */
|
|
const TRANSITION =
|
|
"transition-[color] duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]";
|
|
|
|
/** Body/Large Bold links - 18/28, as the footer frame sets them. */
|
|
const LINK_CLASS = cx(
|
|
FOCUS_RING,
|
|
/* 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";
|
|
|
|
/** 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 = "label-md uppercase text-[var(--text-tertiary)]";
|
|
|
|
/**
|
|
* Internal links go through the router so the footer navigates client-side;
|
|
* anything flagged `external` stays a plain anchor and opens in a new tab.
|
|
*/
|
|
function FooterNavLink({ link, className }: { link: FooterLink; className: string }) {
|
|
if (link.external) {
|
|
return (
|
|
<a href={link.href} className={className} target="_blank" rel="noopener noreferrer">
|
|
{link.label}
|
|
</a>
|
|
);
|
|
}
|
|
return (
|
|
<Link to={link.href} className={className} prefetch="intent">
|
|
{link.label}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export function Footer({
|
|
surface = "dark",
|
|
locale = "en",
|
|
dir,
|
|
linkGroups,
|
|
socialLinks,
|
|
partners = DEFAULT_FOOTER_PARTNERS,
|
|
copy: copyOverrides,
|
|
homeHref,
|
|
className,
|
|
id,
|
|
}: FooterProps) {
|
|
const isDark = surface === "dark";
|
|
// A CMS field that has not been filled in arrives as undefined; spreading it
|
|
// 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 (
|
|
<footer
|
|
id={id}
|
|
dir={dir}
|
|
data-surface={surface}
|
|
className={cx(
|
|
/* 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 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}
|
|
alt="SHARP-ER"
|
|
width={FOOTER_WORDMARK_SIZE.width}
|
|
height={FOOTER_WORDMARK_SIZE.height}
|
|
className="block h-[27px] w-[196px]"
|
|
loading="lazy"
|
|
decoding="async"
|
|
/>
|
|
</Link>
|
|
|
|
{/* 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>
|
|
|
|
{/* 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>
|
|
</>
|
|
)}
|
|
|
|
{/* EU visibility, through the shared lockup the hero uses. */}
|
|
<EuBadge
|
|
statement={copy.euStatement}
|
|
locale={locale}
|
|
surface={surface}
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
|
|
<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-sm">
|
|
{group.links.map((link: FooterLink, linkIndex: number) => (
|
|
<li key={`${link.href}-${linkIndex}`}>
|
|
<FooterNavLink link={link} className={LINK_CLASS} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* ---- implemented by ------------------------------------------ */}
|
|
{partners.length > 0 && (
|
|
<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">
|
|
<PartnerMark isDark={isDark} partner={partner} />
|
|
</li>
|
|
))}
|
|
</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>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
Consortium logo. The three marks are shown together and never on their own
|
|
(communication guideline), so this renders one mark of that row - each keeps
|
|
its own designed box rather than a shared height rule.
|
|
------------------------------------------------------------------------ */
|
|
function PartnerMark({ isDark, partner }: { isDark: boolean; partner: FooterPartner }) {
|
|
const image = (
|
|
<img
|
|
src={isDark ? partner.srcInverse : partner.src}
|
|
alt={partner.name}
|
|
width={partner.width}
|
|
height={partner.height}
|
|
style={{ width: `${partner.width}px`, height: `${partner.height}px` }}
|
|
className="block"
|
|
loading="lazy"
|
|
decoding="async"
|
|
/>
|
|
);
|
|
|
|
if (!partner.href) return image;
|
|
|
|
return (
|
|
<a
|
|
href={partner.href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={cx(FOCUS_RING, "inline-flex")}
|
|
>
|
|
{image}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
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 SocialButton({ link }: { link: FooterSocialLink }) {
|
|
return (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
export default Footer;
|
|
|
|
/* ============================================================================
|
|
Footer.stories-ish - usage, as a comment only (no Storybook in this project)
|
|
----------------------------------------------------------------------------
|
|
|
|
// 1. Dark English footer - the default, matches figma 140:7814.
|
|
// <html lang="en" dir="ltr"> already carries the direction.
|
|
<Footer locale="en" />
|
|
|
|
// 2. Light English footer - figma 140:7812.
|
|
<Footer surface="light" locale="en" />
|
|
|
|
// 3. Arabic, dark. Same markup; the RTL mirroring comes from <html dir="rtl">,
|
|
// so nothing is passed for direction inside the real app.
|
|
<Footer locale="ar" />
|
|
|
|
// 4. Light Arabic - figma 140:8713. `dir` is only needed when the footer is
|
|
// previewed inside a document that is not in that locale.
|
|
<Footer surface="light" locale="ar" dir="rtl" />
|
|
|
|
// 5. Strapi-driven: every list is a prop, so the CMS payload maps 1:1.
|
|
<Footer
|
|
surface="dark"
|
|
locale="en"
|
|
linkGroups={cms.footer.groups}
|
|
socialLinks={cms.footer.social}
|
|
partners={cms.footer.partners}
|
|
copy={{ description: cms.footer.blurb }}
|
|
/>
|
|
|
|
// 6. Sitemap-only footer, no consortium row and no social row.
|
|
<Footer partners={[]} socialLinks={[]} />
|
|
|
|
========================================================================= */
|