Files
sharper.ly/apps/web/app/components/ui/Footer/Footer.tsx
T
IllusionandClaude Opus 5 aedef6daeb 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>
2026-09-16 20:32:56 +02:00

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={[]} />
========================================================================= */