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,273 @@
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type FocusEvent as ReactFocusEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { Link } from "react-router";
import "./LanguageSwitcher.css";
import {
buildLocalizedHref,
getDirection,
LOCALE_ENDONYM,
LOCALES,
type Locale,
} from "./locale-path";
/* Reinforces the global :focus-visible rule in app.css with the same tier-2
focus tokens, so the ring is visible even if a caller overrides `outline`. */
const FOCUS_RING =
"focus-visible:outline-[color:var(--focus-ring-color)] " +
"focus-visible:outline-[length:var(--focus-ring-width)] " +
"focus-visible:outline-offset-[var(--focus-ring-offset)]";
const TRANSITION =
"transition-colors duration-[var(--motion-duration-fast)] ease-[var(--motion-ease-standard)]";
type OptionPosition = "first" | "last";
export interface LanguageMenuProps {
/** The locale the reader is currently on. */
locale: Locale;
/** Current pathname, e.g. "/en/news-and-updates". */
pathname: string;
/** Query string to carry across the switch. Usually `location.search`. */
search?: string;
/** Fragment to carry across the switch. Usually `location.hash`. */
hash?: string;
/**
* Accessible name of the globe, in the reader's language ("Language"). It is
* required because the globe carries no text of its own.
*/
label: string;
/** Navigate with a full document request instead of a client transition. */
reloadDocument?: boolean;
className?: string;
}
/**
* The navigation's language control: the globe (figma 478:16381) as a button
* that opens a short list of the site's languages, each linking to the page the
* reader is on under that locale, with the query string and fragment kept.
*
* Not drawn in Figma - the design shows the globe alone - so the list is built
* from the tier-2 surface, border and elevation tokens a popover uses, and
* hangs from the globe's inline-end edge so it opens inward in both directions.
*
* A disclosure rather than an ARIA menu: the entries are ordinary links and are
* announced and followed as links. Each carries its own lang/hrefLang and its
* label its own dir, so a screen reader switches voice for "العربية". The
* current language is marked with aria-current and set in bold, so the state is
* not carried by colour alone. ArrowDown/ArrowUp open the list and move through it, Escape closes it
* and returns focus to the globe, and an outside press or focus leaving closes
* it. Focus is never trapped.
*/
export function LanguageMenu({
locale,
pathname,
search,
hash,
label,
reloadDocument = false,
className,
}: LanguageMenuProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const pendingFocus = useRef<OptionPosition | null>(null);
const rawId = useId();
const listId = `sharper-language-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const options = useCallback(
(): HTMLElement[] =>
Array.from(
rootRef.current?.querySelectorAll<HTMLElement>("[data-language-option]") ??
[],
),
[],
);
const focusOption = useCallback(
(position: OptionPosition) => {
const all = options();
(position === "first" ? all[0] : all[all.length - 1])?.focus();
},
[options],
);
const close = useCallback(() => {
pendingFocus.current = null;
setOpen(false);
}, []);
/* Outside press. pointerdown rather than click so the list is already gone
by the time the click lands on whatever is underneath. */
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
/* A keyboard request to open and step into the list can only be honoured
once the list has rendered. */
useEffect(() => {
const position = pendingFocus.current;
if (!open || position === null) return;
pendingFocus.current = null;
focusOption(position);
}, [open, focusOption]);
const openAndFocus = (position: OptionPosition) => {
if (open) {
focusOption(position);
return;
}
pendingFocus.current = position;
setOpen(true);
};
const handleButtonKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowDown") {
event.preventDefault();
openAndFocus("first");
} else if (event.key === "ArrowUp") {
event.preventDefault();
openAndFocus("last");
}
};
const handleOptionKeyDown = (event: ReactKeyboardEvent<HTMLAnchorElement>) => {
const all = options();
const index = all.indexOf(event.currentTarget);
const moveTo = (to: number) => all[(to + all.length) % all.length]?.focus();
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveTo(index + 1);
break;
case "ArrowUp":
event.preventDefault();
moveTo(index - 1);
break;
case "Home":
event.preventDefault();
moveTo(0);
break;
case "End":
event.preventDefault();
moveTo(all.length - 1);
break;
default:
break;
}
};
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Escape" || !open) return;
event.preventDefault();
close();
buttonRef.current?.focus();
};
/* React's onBlur is focusout, so it fires for descendants too. */
const handleBlur = (event: ReactFocusEvent<HTMLDivElement>) => {
const next = event.relatedTarget as Node | null;
if (!next || !rootRef.current?.contains(next)) close();
};
return (
<div
ref={rootRef}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className={["relative", className].filter(Boolean).join(" ")}
>
<button
ref={buttonRef}
type="button"
aria-expanded={open}
aria-controls={listId}
aria-label={label}
title={label}
onClick={() => (open ? close() : setOpen(true))}
onKeyDown={handleButtonKeyDown}
className={[
"inline-flex shrink-0 cursor-pointer items-center justify-center rounded-control",
"text-[var(--text-primary)] hover:text-[var(--text-accent)]",
"aria-expanded:text-[var(--text-accent)]",
TRANSITION,
FOCUS_RING,
].join(" ")}
>
<span aria-hidden="true" className="sh-language-globe" />
</button>
<ul
id={listId}
hidden={!open}
className={[
"absolute end-0 top-full z-[var(--layer-dropdown)] mt-sm",
"flex min-w-[10rem] flex-col gap-2xs p-2xs",
"rounded-control border border-[var(--border-subtle)]",
"bg-[var(--surface-raised)] shadow-[var(--elevation-popover)]",
].join(" ")}
>
{LOCALES.map((option) => {
const current = option === locale;
return (
<li key={option}>
<Link
to={buildLocalizedHref(pathname, option, { search, hash })}
reloadDocument={reloadDocument}
hrefLang={option}
lang={option}
aria-current={current ? "true" : undefined}
data-language-option={option}
onClick={close}
onKeyDown={handleOptionKeyDown}
className={[
current ? "body-md-bold" : "body-md",
"block rounded-control px-sm py-xs text-start no-underline",
"text-[var(--text-primary)] hover:bg-[var(--surface-muted)]",
"aria-[current=true]:text-[var(--text-accent)]",
TRANSITION,
FOCUS_RING,
].join(" ")}
>
{/* The label carries its own direction; the row does not, so
both names line up on the page's inline-start edge instead
of each hugging the side its own script reads from. */}
<span dir={getDirection(option)}>{LOCALE_ENDONYM[option]}</span>
</Link>
</li>
);
})}
</ul>
</div>
);
}
export default LanguageMenu;
/* ---------------------------------------------------------------------------
USAGE
---------------------------------------------------------------------------
// In the navigation bar, wired from the router's location:
const { pathname, search, hash } = useLocation();
<LanguageMenu
label={strings.languageMenuLabel}
locale={locale}
pathname={pathname}
search={search}
hash={hash}
/>
------------------------------------------------------------------------ */
@@ -0,0 +1,35 @@
/* ============================================================================
LANGUAGE SWITCHER - figma `globe` 478:16381 (navigation 20:236)
----------------------------------------------------------------------------
The navigation now carries the language control as a bare 24x24 globe in the
Actions slot, replacing the "AR" / "EN" pill that used to sit next to the EU
badge. The exported asset fills its path with a hard-coded #1D1D1B, which
would be invisible on the dark surface, so it is applied as a mask over
`currentColor` - the same technique as `.sh-arrow-icon` and `.sh-card-icon`.
That keeps the exact exported geometry while the colour comes from whichever
semantic token the bar has in force.
GEOMETRY, straight from Figma: a 24x24 box (`--control-icon-size`) with the
glyph filling it edge to edge. The globe is radially symmetric, so unlike the
arrow it needs no RTL mirroring.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
.sh-language-globe {
display: block;
flex: none;
inline-size: var(--control-icon-size);
block-size: var(--control-icon-size);
background-color: currentColor;
-webkit-mask-image: url("/assets/icons/globe-solid.svg");
mask-image: url("/assets/icons/globe-solid.svg");
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
}
}
@@ -42,7 +42,7 @@ export interface LanguageSwitcherProps {
* document. Turn it on if a page needs the server to re-render first.
*/
reloadDocument?: boolean;
/** "sm" for the desktop bar, "md" for a 44px touch target in the drawer. */
/** "sm" for a compact pill, "md" for a 44px touch target in the drawer. */
size?: LanguageSwitcherSize;
className?: string;
}
@@ -52,9 +52,8 @@ export interface LanguageSwitcherProps {
* ("AR" while reading English, "EN" while reading Arabic) and links to the
* same page under that locale, preserving the rest of the path.
*
* The Figma navigation has no language switcher - a gap recorded in
* docs/SHARP-ER_Website Recommendations.docx - so this is designed to sit in
* the existing bar: label-md on a pill outline, matching the EU badge height.
* The mobile drawer uses this, where a spelled-out target is clearer than an
* icon. The desktop bar uses <LanguageMenu />, the globe with a dropdown.
*/
export function LanguageSwitcher({
locale,
@@ -114,8 +113,4 @@ export default LanguageSwitcher;
// Force a document navigation (server re-renders <html lang>/<html dir>):
<LanguageSwitcher locale={locale} pathname={pathname} reloadDocument />
// Wired from a route, using the router's own location:
const { pathname, search, hash } = useLocation();
<LanguageSwitcher locale={locale} pathname={pathname} search={search} hash={hash} />
------------------------------------------------------------------------ */
@@ -0,0 +1,119 @@
import { Link } from "react-router";
import "./LanguageSwitcher.css";
import {
getCounterpartLocale,
getCounterpartPath,
getDirection,
LOCALE_ENDONYM,
LOCALE_SHORT_LABEL,
type Locale,
} from "./locale-path";
/* Reinforces the global :focus-visible rule in app.css with the same tier-2
focus tokens, so the ring is visible even if a caller overrides `outline`. */
const FOCUS_RING =
"focus-visible:outline-[color:var(--focus-ring-color)] " +
"focus-visible:outline-[length:var(--focus-ring-width)] " +
"focus-visible:outline-offset-[var(--focus-ring-offset)]";
export interface LanguageToggleProps {
/** The locale the reader is currently on. */
locale: Locale;
/** Current pathname, e.g. "/en/news-and-updates". */
pathname: string;
/** Query string to carry across the switch. Usually `location.search`. */
search?: string;
/** Fragment to carry across the switch. Usually `location.hash`. */
hash?: string;
/** Accessible name of the pair ("Language"), in the reader's language. */
label: string;
/** Navigate with a full document request instead of a client transition. */
reloadDocument?: boolean;
className?: string;
}
/**
* Both languages, side by side: the globe, the current locale in an accent
* chip, and the other locale as a link to the same page under that locale -
* the redesign's nav control, replacing the globe-plus-dropdown on the desktop
* bar.
*
* One tap to switch and no state to open, which is why the draft prefers it;
* the drawer still uses <LanguageSwitcher /> and <LanguageMenu /> remains for
* anywhere a list of more than two languages is needed.
*
* The current language is marked with `aria-current`, not by colour alone. The
* link carries its own lang/hrefLang/dir and is named with the target
* language's endonym ("العربية"), so a screen reader switches voice for it
* while the visible "AR" stays a two-letter code.
*/
export function LanguageToggle({
locale,
pathname,
search,
hash,
label,
reloadDocument = false,
className,
}: LanguageToggleProps) {
const target = getCounterpartLocale(locale);
const href = getCounterpartPath(pathname, locale, { search, hash });
return (
<div
role="group"
aria-label={label}
className={[
"flex shrink-0 items-center gap-xs text-[var(--text-secondary)]",
className,
]
.filter(Boolean)
.join(" ")}
>
<span aria-hidden="true" className="sh-language-globe" />
<span className="flex items-center">
<span
aria-current="true"
lang={locale}
dir={getDirection(locale)}
className="label-md rounded-control bg-[var(--surface-accent)] px-xs py-2xs text-[var(--text-on-accent)]"
>
{LOCALE_SHORT_LABEL[locale]}
</span>
<Link
to={href}
reloadDocument={reloadDocument}
hrefLang={target}
lang={target}
dir={getDirection(target)}
aria-label={LOCALE_ENDONYM[target]}
title={LOCALE_ENDONYM[target]}
data-language-switcher={target}
className={[
"label-md rounded-control px-xs py-2xs no-underline",
"text-[var(--text-secondary)]",
"transition-colors duration-[var(--motion-duration-fast)]",
"ease-[var(--motion-ease-standard)]",
"hover:text-[var(--text-primary)]",
FOCUS_RING,
].join(" ")}
>
<span aria-hidden="true">{LOCALE_SHORT_LABEL[target]}</span>
</Link>
</span>
</div>
);
}
export default LanguageToggle;
/* ---------------------------------------------------------------------------
USAGE
---------------------------------------------------------------------------
// Reading English at /en/news-and-updates -> "EN" chipped, "AR" links to
// /ar/news-and-updates.
<LanguageToggle locale="en" pathname="/en/news-and-updates" label="Language" />
------------------------------------------------------------------------ */
@@ -3,6 +3,10 @@ export type {
LanguageSwitcherProps,
LanguageSwitcherSize,
} from "./LanguageSwitcher";
export { LanguageMenu } from "./LanguageMenu";
export type { LanguageMenuProps } from "./LanguageMenu";
export { LanguageToggle } from "./LanguageToggle";
export type { LanguageToggleProps } from "./LanguageToggle";
export {
buildLocalizedHref,