Files
sharper.ly/apps/web/app/routes/about.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

407 lines
16 KiB
TypeScript

/* ============================================================================
ABOUT - /en/about and /ar/about
----------------------------------------------------------------------------
The redesigned About page ("About", Claude Design draft), carried onto the
project's own design system. Five bands, alternating the two dark surfaces
the way the draft alternates #0B0B0A and #1D1D1B:
Hero eyebrow, headline and lead on the sunken surface, with the
two drifting washes behind it.
Facts a four-cell seam grid: the run, the coverage, the lead
partner and the funder.
Nationwide the approved body prose beside the three focus areas.
Consortium the three members as role / name / deliverable panels, under
a header row that carries the EU funding line.
Legacy the SHARP I and II panel with the accent start-edge, and the
call to action into What We Do.
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
--------------------------------------------
* Nothing was cut for sourcing: every fact, role and sentence the draft
prints is in section 1 of docs/SHARPER_Website_Draft_Section_EN_AR.docx or
in the project brief. What the draft does NOT print - a figure of any kind
- stays absent, because the draft marks every statistic "TBD".
* The draft's coloured hover glow (a green box-shadow under a hovered card)
is deliberately dropped; the tiles only rise.
Every block is the FALLBACK: api::about-page.about-page overrides it field
by field as soon as an editor fills it in.
MOTION: classes from _page-kit/motion.css - the staggered hero entrance, the
drifting washes, the scroll-driven band reveals and the hover lift.
There is no Arabic frame in the draft, so /ar is this same DOM under
`dir="rtl"`: every inline edge below is logical (ps-/pe-/start-/end-, or an
explicit `border-inline-start-*`), never left/right.
========================================================================= */
import { Link } from "react-router";
import { ArrowIcon, Button } from "~/components/ui/Button";
import { createTranslator, sectionPath } from "~/i18n";
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
import type { Route } from "./+types/about";
import {
isRichTextBlock,
type CmsAboutPage,
} from "./_page-kit/cms-types";
import { list, loadSingle, toImage } from "./_page-kit/cms.server";
import { pageCopy } from "./_page-kit/copy";
import { routeLocale } from "./_page-kit/locale";
import { RichText } from "./_page-kit/rich-text";
import {
InfoPanel,
PageHero,
PageSection,
SectionHeader,
} from "./_page-kit/sections";
import "./_page-kit/motion.css";
export async function loader({ params }: Route.LoaderArgs) {
const locale = routeLocale(params);
const page = await loadSingle<CmsAboutPage>("about-page", {
locale,
populate: {
hero: { populate: { media: true, ctas: true } },
consortium: { populate: { partner: { populate: { logo: true } } } },
pillars: true,
legacyLink: true,
blocks: { on: { "blocks.rich-text": { populate: "*" } } },
seo: { populate: { ogImage: true } },
},
});
return {
locale,
hero: {
eyebrow: page?.hero?.eyebrow ?? null,
headline: page?.hero?.headline ?? null,
subheading: page?.hero?.subheading ?? null,
media: toImage(page?.hero?.media, "large") ?? null,
},
body: page?.body ?? null,
richTextBlocks: list(page?.blocks)
.filter(isRichTextBlock)
.map((block) => ({ id: block.id, body: block.body })),
pillars: list(page?.pillars).map((pillar) => ({
title: pillar.title,
description: pillar.description ?? "",
})),
consortium: list(page?.consortium)
.filter((member) => member.partner !== null && member.partner !== undefined)
.map((member) => ({
id: member.partner?.documentId ?? String(member.id ?? member.partner?.id),
name: member.partner?.name ?? "",
/* The chip and the line under the name are separate fields, so a
CMS-authored member reads exactly like the bundled copy. */
role: member.role ?? null,
description: member.roleDescription ?? member.partner?.description ?? "",
website: member.partner?.website ?? null,
})),
legacy: {
headline: page?.legacyHeadline ?? null,
body: page?.legacyBody ?? null,
link: page?.legacyLink
? {
label: page.legacyLink.label,
href: page.legacyLink.href,
isExternal: page.legacyLink.isExternal ?? false,
}
: null,
},
seo: {
title: page?.seo?.metaTitle ?? null,
description: page?.seo?.metaDescription ?? null,
image: toImage(page?.seo?.ogImage, "large") ?? null,
noIndex: page?.seo?.noIndex ?? false,
},
};
}
export function meta({ loaderData, matches }: Route.MetaArgs) {
const root = matches[0].loaderData;
const locale = loaderData?.locale ?? root.locale;
const t = createTranslator(locale);
const copy = pageCopy(locale).about;
return buildMeta({
locale,
pathname: root.pathname,
siteUrl: root.siteUrl,
title: loaderData?.seo.title ?? t("nav.sections.about"),
description: loaderData?.seo.description ?? copy.lead,
...(loaderData?.seo.image
? { image: { url: loaderData.seo.image.src, alt: loaderData.seo.image.alt } }
: {}),
noIndex: loaderData?.seo.noIndex ?? false,
jsonLd: buildBreadcrumbJsonLd(root.siteUrl, [
{ name: t("nav.sections.home"), pathname: sectionPath("home", locale) },
{ name: t("nav.sections.about"), pathname: sectionPath("about", locale) },
]),
});
}
/**
* The accent start-edge the draft paints down the left of its closing panel.
* Written as arbitrary properties rather than `border-s-4`: the edge needs its
* own colour as well as its own width, and `border-inline-start-*` is the only
* spelling that mirrors in Arabic without an `rtl:` override.
*/
const ACCENT_EDGE =
"[border-inline-start-color:var(--border-accent)] [border-inline-start-width:4px]";
/** One member of the consortium, however it reached the page. */
interface ConsortiumMember {
id: string;
name: string;
/** The role chip - `consortium.role` in the CMS, or the bundled copy's. */
role: string | null;
description: string;
website: string | null;
}
export default function About({ loaderData }: Route.ComponentProps) {
const { locale, hero, body, richTextBlocks, legacy } = loaderData;
const t = createTranslator(locale);
const copy = pageCopy(locale).about;
const pillars = loaderData.pillars.length > 0 ? loaderData.pillars : copy.pillars;
const consortium: readonly ConsortiumMember[] =
loaderData.consortium.length > 0
? loaderData.consortium.map((member) => ({
id: member.id,
name: member.name,
role: member.role,
description: member.description,
website: member.website,
}))
: copy.consortium.map((member) => ({
id: member.name,
name: member.name,
role: member.role,
description: member.description,
website: null,
}));
return (
<>
<PageHero
className="border-b border-[var(--border-subtle)] bg-[var(--surface-sunken)] [--layout-hero-bottom:clamp(3rem,6vw,4.5rem)]"
title={hero.headline ?? copy.heroHeadline}
eyebrow={hero.eyebrow ?? copy.heroEyebrow}
lead={hero.subheading ?? copy.lead}
ambient={[{ top: -260, from: "start", offset: "6%", width: 760, height: 600, durationSeconds: 20 }]}
reveal
{...(hero.media ? { media: hero.media } : {})}
/>
{/* THE FACTS BAND - four cells divided by a one-pixel seam, which is the
band's own background showing between them. Facts, not figures: the
run, the coverage, the lead partner and the funder, each written out
in the approved draft. */}
<PageSection className="border-b border-[var(--border-subtle)] bg-[var(--surface-sunken)] pt-0 pb-0 md:pt-0 md:pb-0">
<dl className="grid grid-cols-1 gap-[1px] bg-[var(--border-subtle)] sm:grid-cols-2 lg:grid-cols-4">
{copy.facts.map((fact) => (
<div
key={fact.label}
className="sh-reveal flex flex-col gap-2xs bg-[var(--surface-sunken)] p-lg transition-colors duration-[var(--motion-duration-base)] hover:bg-[var(--surface-raised)]"
>
<dt className="label-md uppercase text-[var(--text-tertiary)]">
{fact.label}
</dt>
<dd className="body-lg-bold text-[var(--text-primary)]">
{fact.value}
</dd>
</div>
))}
</dl>
</PageSection>
{/* A NATIONWIDE PROJECT, DELIVERED LOCALLY - the approved body prose on
the inline-start side, the three focus areas stacked beside it. The
band is the raised dark the draft paints (#1d1d1b) and has to say so:
the document ground is now the sunken near-black. */}
<PageSection
aria-labelledby="about-body-heading"
band
>
<div className="grid grid-cols-1 items-start gap-2xl lg:grid-cols-2 lg:gap-4xl">
<div className="sh-reveal flex flex-col gap-md">
<SectionHeader id="about-body-heading" title={copy.bodyHeading} />
{body ? (
<RichText markdown={body} />
) : (
/* Two paragraphs, as the draft sets them - the run and the
consortium first, then what the project does. */
copy.body.map((paragraph) => (
<p
key={paragraph}
className="body-md text-[var(--text-secondary)]"
>
{paragraph}
</p>
))
)}
{richTextBlocks.map((block) => (
<RichText key={block.id} markdown={block.body} />
))}
</div>
<div className="flex flex-col gap-md">
{/* The draft prints no heading over this column; the heading exists
for the document outline and for screen readers. */}
<h3 id="about-pillars-heading" className="sr-only">
{copy.pillarsHeading}
</h3>
<ul
aria-labelledby="about-pillars-heading"
className="flex list-none flex-col gap-md"
>
{pillars.map((pillar) => (
<li key={pillar.title}>
<InfoPanel
className="sh-lift sh-reveal sh-reveal--in hover:border-[var(--border-accent)]"
title={pillar.title}
titleAs="h4"
>
{pillar.description}
</InfoPanel>
</li>
))}
</ul>
</div>
</div>
</PageSection>
{/* THE CONSORTIUM */}
{/* The draft draws no rule between the raised band above and this one -
the change of surface is the seam - so this band carries none. */}
<PageSection
aria-labelledby="about-consortium-heading"
className="bg-[var(--surface-sunken)]"
>
<div className="flex flex-col gap-2xl">
<div className="sh-reveal flex flex-wrap items-end justify-between gap-lg border-b border-[var(--border-default)] pb-lg">
<SectionHeader
id="about-consortium-heading"
title={copy.consortiumHeading}
/>
{/* EU visibility, as the draft draws it beside this heading. The
emblem itself is carried by the navigation and the footer. */}
<p className="label-md uppercase text-[var(--text-tertiary)]">
{t("site.euStatement")}
</p>
</div>
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
{consortium.map((member) => (
<li key={member.id}>
<InfoPanel
/* On the sunken band the draft lifts these cards to the
raised dark it uses for a page band (#1d1d1b), one step
lighter than the panel shade InfoPanel defaults to. The
`!` is what makes the override beat the component's own
`bg-*` utility, which sorts after this one. */
className="sh-lift sh-reveal sh-reveal--in hover:border-[var(--border-accent)]"
title={member.name}
{...(member.role
? { eyebrow: member.role, eyebrowVariant: "chip" as const }
: {})}
>
<span className="block">{member.description}</span>
{member.website ? (
<a
href={member.website}
target="_blank"
rel="noopener noreferrer"
className="mt-xs inline-block body-sm-bold text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
>
{member.name}
</a>
) : null}
</InfoPanel>
</li>
))}
</ul>
</div>
</PageSection>
{/* SHARP I AND II, AND THE WAY ON TO WHAT WE DO */}
<PageSection
aria-labelledby="about-legacy-heading"
className="bg-[var(--surface-sunken)] pt-0 md:pt-0"
>
<div
className={`sh-reveal flex flex-wrap items-center justify-between gap-xl rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-lg md:p-xl ${ACCENT_EDGE}`}
>
<div className="flex max-w-[44rem] flex-col gap-sm">
<SectionHeader
id="about-legacy-heading"
eyebrow={copy.legacyEyebrow}
title={legacy.headline ?? copy.legacyHeading}
/>
{legacy.body ? (
<RichText markdown={legacy.body} />
) : (
<p className="body-md text-[var(--text-secondary)]">
{copy.legacyBody}
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-md">
<Button
as={Link}
to={sectionPath("whatWeDo", locale)}
prefetch="intent"
size="lg"
iconEnd={<ArrowIcon />}
>
{t("actions.learnWhatWeDo")}
</Button>
{/* The draft marks the legacy page as "[link: …]" with no target
yet, so this second button appears only once the CMS gives it
a real one. */}
{legacy.link ? (
legacy.link.isExternal ? (
<Button
as="a"
href={legacy.link.href}
target="_blank"
rel="noopener noreferrer"
variant="secondary"
size="lg"
iconEnd={<ArrowIcon />}
>
{legacy.link.label}
</Button>
) : (
<Button
as={Link}
to={legacy.link.href}
prefetch="intent"
variant="secondary"
size="lg"
iconEnd={<ArrowIcon />}
>
{legacy.link.label}
</Button>
)
) : null}
</div>
</div>
</PageSection>
</>
);
}