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:
+202
-111
@@ -13,18 +13,40 @@
|
||||
|
||||
Every message the reader can see is localised: field labels and the generic
|
||||
errors come from the i18n catalogue, the page prose from the approved draft.
|
||||
|
||||
THE REDESIGN
|
||||
------------
|
||||
The Claude Design draft ("Contact") lays the page out as two columns on the
|
||||
sunken surface: the form in a raised panel, and beside it the channels, the
|
||||
consortium disclosure list and the EU funding note. Motion is the classes in
|
||||
_page-kit/motion.css - the hero entrance and washes, the band reveals, the
|
||||
panel that rises when the message is sent, and the answer that opens under a
|
||||
question. The draft's coloured hover glow is deliberately dropped.
|
||||
|
||||
WHAT THE DRAFT SHIPS THAT THIS PAGE DOES NOT
|
||||
* "The project team replies within five working days." No approved
|
||||
document makes that commitment, so it is not published (CLAUDE.md 5).
|
||||
* "info@sharper.ly" and "Facebook - SHARP-ER Libya". The approved draft
|
||||
writes both as placeholders ("[project email - e.g. ...]", "[Facebook
|
||||
page link placeholder]"), so the address and the social links come from
|
||||
the CMS and the page says plainly that they are still to be published.
|
||||
* The three consortium addresses, which the draft fills with "Contact
|
||||
details TBD" - the disclosure list renders only what the CMS holds.
|
||||
========================================================================= */
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { SITE_FACEBOOK_URL } from "~/lib/site-links";
|
||||
import { data, Form, Link, useLocation, useNavigation } from "react-router";
|
||||
|
||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||
import { EuBadge } from "~/components/ui/EuBadge";
|
||||
import { Input, Textarea } from "~/components/ui/Input";
|
||||
import { createTranslator, sectionPath, type Locale } from "~/i18n";
|
||||
import { getServerEnv } from "~/lib/env.server";
|
||||
import { buildBreadcrumbJsonLd, buildMeta } from "~/lib/seo";
|
||||
|
||||
import type { Route } from "./+types/contact";
|
||||
import { Accordion, type AccordionItem } from "./_page-kit/accordion";
|
||||
import type { CmsContactPage } from "./_page-kit/cms-types";
|
||||
import { list, loadSingle, toImage } from "./_page-kit/cms.server";
|
||||
import { pageCopy } from "./_page-kit/copy";
|
||||
@@ -32,13 +54,13 @@ import { routeLocale } from "./_page-kit/locale";
|
||||
import { RichText } from "./_page-kit/rich-text";
|
||||
import {
|
||||
EmptyState,
|
||||
InfoPanel,
|
||||
PageHero,
|
||||
PageSection,
|
||||
Prose,
|
||||
SectionHeader,
|
||||
} from "./_page-kit/sections";
|
||||
|
||||
import "./_page-kit/motion.css";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
LOADER
|
||||
------------------------------------------------------------------------ */
|
||||
@@ -283,6 +305,51 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
PAGE
|
||||
------------------------------------------------------------------------ */
|
||||
|
||||
/** See the note on the same constant in about.tsx. */
|
||||
const ACCENT_EDGE =
|
||||
"[border-inline-start-color:var(--border-accent)] [border-inline-start-width:4px]";
|
||||
|
||||
/** The tick the draft draws in a filled circle over the "message sent" panel. */
|
||||
function SentMark() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-12 shrink-0 items-center justify-center rounded-pill bg-[var(--surface-accent)] text-[var(--text-on-accent)]"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M5 13l4.5 4.5L19 7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One label-and-value row of the channels list. */
|
||||
function Channel({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2xs border-b border-[var(--border-subtle)] pb-md">
|
||||
<dt className="label-md uppercase text-[var(--text-tertiary)]">{label}</dt>
|
||||
{/* The draft sets the value itself in the page's primary tone, a step
|
||||
above the label. */}
|
||||
<dd className="body-lg-bold text-[var(--text-primary)]">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LINK_CLASS =
|
||||
"text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]";
|
||||
|
||||
export default function Contact({
|
||||
loaderData,
|
||||
actionData,
|
||||
@@ -292,6 +359,13 @@ export default function Contact({
|
||||
const t = createTranslator(locale);
|
||||
const copy = pageCopy(locale).contact;
|
||||
|
||||
/* The CMS wins; until an editor fills the row, the project's own confirmed
|
||||
channel stands in (lib/site-links.ts). */
|
||||
const channels =
|
||||
socialLinks.length > 0
|
||||
? socialLinks
|
||||
: [{ label: copy.facebookLabel, href: SITE_FACEBOOK_URL, isExternal: true }];
|
||||
|
||||
const navigation = useNavigation();
|
||||
const { pathname } = useLocation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
@@ -305,6 +379,32 @@ export default function Contact({
|
||||
(message): message is string => typeof message === "string",
|
||||
);
|
||||
|
||||
/* The draft draws the consortium addresses as a disclosure list, which the
|
||||
approved draft also calls for ("optional accordion"). The answers are
|
||||
Markdown because that is what <Accordion /> renders; every value in them
|
||||
comes from the CMS, and the whole list is replaced by the localised empty
|
||||
state while the addresses are still "TBD". */
|
||||
const consortiumItems: readonly AccordionItem[] = consortiumContacts.map(
|
||||
(contact) => {
|
||||
const place = [contact.city, contact.country].filter(Boolean).join(", ");
|
||||
|
||||
return {
|
||||
id: contact.id,
|
||||
question: contact.organisation,
|
||||
answer: [
|
||||
contact.addressLine,
|
||||
place === "" ? null : place,
|
||||
contact.email ? `[${contact.email}](mailto:${contact.email})` : null,
|
||||
contact.phone
|
||||
? `[${contact.phone}](tel:${contact.phone.replace(/\s+/g, "")})`
|
||||
: null,
|
||||
]
|
||||
.filter((line): line is string => typeof line === "string")
|
||||
.join("\n\n"),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/* A failed submit re-renders the page with the summary at the top of the
|
||||
form. Screen-reader users are left wherever they were, and keyboard users
|
||||
below the fold, so focus is moved onto the summary itself - it is
|
||||
@@ -320,32 +420,47 @@ export default function Contact({
|
||||
return (
|
||||
<>
|
||||
<PageHero
|
||||
className="border-b border-[var(--border-subtle)] bg-[var(--surface-sunken)] [--layout-hero-bottom:clamp(2.5rem,5vw,3.75rem)]"
|
||||
title={hero.headline ?? copy.heroHeadline}
|
||||
eyebrow={hero.eyebrow ?? t("nav.sections.engagementAndOpportunities")}
|
||||
eyebrow={hero.eyebrow ?? t("nav.sections.contact")}
|
||||
lead={hero.subheading ?? copy.lead}
|
||||
ambient={[{ top: -250, from: "start", offset: "6%", width: 720, height: 560, durationSeconds: 21 }]}
|
||||
reveal
|
||||
{...(hero.media ? { media: hero.media } : {})}
|
||||
/>
|
||||
|
||||
<PageSection className="pt-0 md:pt-0">
|
||||
<div className="grid grid-cols-1 gap-2xl lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
|
||||
<PageSection className="bg-[var(--surface-sunken)]">
|
||||
{/* Two equal columns, as the draft sets them - the form beside the
|
||||
channels, each starting at the top of the band. */}
|
||||
<div className="grid grid-cols-1 items-start gap-2xl lg:grid-cols-2 lg:gap-4xl">
|
||||
{/* ---------------------------------------------------------------
|
||||
THE FORM
|
||||
THE FORM - one raised panel on the sunken band, as the draft has
|
||||
it. The mechanism underneath is unchanged: a real HTML form post
|
||||
that the server validates.
|
||||
--------------------------------------------------------------- */}
|
||||
<div className="flex flex-col gap-lg">
|
||||
<SectionHeader
|
||||
id="contact-form-heading"
|
||||
title={copy.formHeading}
|
||||
lead={copy.formIntro}
|
||||
/>
|
||||
<div className="sh-reveal sh-reveal--in flex flex-col gap-lg rounded-card border border-[var(--border-subtle)] bg-[var(--surface-card)] p-lg md:p-xl">
|
||||
{/* The draft opens the panel straight onto the fields; the heading
|
||||
is what labels the form in the outline and for a screen
|
||||
reader. */}
|
||||
<h2 id="contact-form-heading" className="sr-only">
|
||||
{copy.formHeading}
|
||||
</h2>
|
||||
|
||||
{isSuccess ? (
|
||||
/* The draft swaps the fields for this acknowledgement inside the
|
||||
same panel - no box of its own. */
|
||||
<div
|
||||
className="flex flex-col items-start gap-md rounded-card border border-[var(--border-accent)] bg-[var(--surface-accent-subtle)] p-lg"
|
||||
className="sh-rise flex flex-col items-start gap-md"
|
||||
role="status"
|
||||
>
|
||||
<p className="body-lg-bold text-[var(--text-primary)]">
|
||||
<SentMark />
|
||||
|
||||
<p className="heading-h3 text-[var(--text-primary)]">
|
||||
{t("form.successTitle")}
|
||||
</p>
|
||||
{/* The draft promises a reply "within five working days"; no
|
||||
approved document does, so the generic acknowledgement from
|
||||
the message catalogue stands instead. */}
|
||||
<p className="body-md text-[var(--text-secondary)]">
|
||||
{t("form.successBody")}
|
||||
</p>
|
||||
@@ -405,7 +520,9 @@ export default function Contact({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-lg md:grid-cols-2">
|
||||
{/* One column at every width - the draft stacks the four
|
||||
fields above the message. */}
|
||||
<div className="flex flex-col gap-lg">
|
||||
<Input
|
||||
name="name"
|
||||
id={fieldDomId("name")}
|
||||
@@ -474,128 +591,102 @@ export default function Contact({
|
||||
</div>
|
||||
|
||||
{/* ---------------------------------------------------------------
|
||||
CONTACT DETAILS
|
||||
THE CHANNELS COLUMN
|
||||
--------------------------------------------------------------- */}
|
||||
<div className="flex flex-col gap-lg">
|
||||
<SectionHeader id="contact-details-heading" title={copy.detailsHeading} />
|
||||
<div className="flex flex-col gap-xl">
|
||||
<div className="sh-reveal flex flex-col gap-md">
|
||||
{/* The draft opens this column straight onto the channel rows;
|
||||
the heading is here for the outline only. */}
|
||||
<h2 id="contact-details-heading" className="sr-only">
|
||||
{copy.detailsHeading}
|
||||
</h2>
|
||||
|
||||
{intro ? (
|
||||
<Prose>
|
||||
<RichText markdown={intro} />
|
||||
</Prose>
|
||||
) : null}
|
||||
{intro ? (
|
||||
<Prose>
|
||||
<RichText markdown={intro} />
|
||||
</Prose>
|
||||
) : null}
|
||||
|
||||
<dl className="flex flex-col gap-md rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-lg">
|
||||
<div className="flex flex-col gap-2xs">
|
||||
<dt className="label-md uppercase text-[var(--text-tertiary)]">
|
||||
{copy.generalEnquiries}
|
||||
</dt>
|
||||
<dd className="body-md text-[var(--text-secondary)]">
|
||||
<dl className="flex flex-col gap-md">
|
||||
<Channel label={copy.generalEnquiries}>
|
||||
{generalEmail ? (
|
||||
<a
|
||||
href={`mailto:${generalEmail}`}
|
||||
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
>
|
||||
<a href={`mailto:${generalEmail}`} className={LINK_CLASS}>
|
||||
{generalEmail}
|
||||
</a>
|
||||
) : (
|
||||
copy.contactPending
|
||||
/* The approved draft writes the address as a placeholder,
|
||||
so nothing stands in for it. A sentence is not a channel
|
||||
value, so it keeps the quieter body tone. */
|
||||
<span className="body-md text-[var(--text-secondary)]">
|
||||
{copy.contactPending}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</Channel>
|
||||
|
||||
<div className="flex flex-col gap-2xs">
|
||||
<dt className="label-md uppercase text-[var(--text-tertiary)]">
|
||||
{copy.websiteLabel}
|
||||
</dt>
|
||||
<dd className="body-md">
|
||||
<a
|
||||
href={copy.websiteHref}
|
||||
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
>
|
||||
<Channel label={copy.websiteLabel}>
|
||||
<a href={copy.websiteHref} className={LINK_CLASS}>
|
||||
{loaderData.websiteLabel ?? copy.websiteText}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</Channel>
|
||||
|
||||
{socialLinks.length > 0 ? (
|
||||
<div className="flex flex-col gap-2xs">
|
||||
<dt className="label-md uppercase text-[var(--text-tertiary)]">
|
||||
{t("site.followUs")}
|
||||
</dt>
|
||||
<dd>
|
||||
<ul className="flex flex-wrap gap-md body-md">
|
||||
{socialLinks.map((link) => (
|
||||
{/* The CMS owns this row; until an editor fills it, the
|
||||
project's own confirmed Facebook page stands in. */}
|
||||
{channels.length > 0 ? (
|
||||
<Channel label={t("site.followUs")}>
|
||||
<ul className="flex flex-wrap gap-md">
|
||||
{channels.map((link) => (
|
||||
<li key={link.href}>
|
||||
<a
|
||||
href={link.href}
|
||||
{...(link.isExternal
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: {})}
|
||||
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</Channel>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* CONSORTIUM CONTACTS - "TBD" in the draft, so the CMS owns them.
|
||||
The draft labels the list with an accent eyebrow rather than a
|
||||
section heading, so the heading takes the label role. */}
|
||||
<div className="sh-reveal flex flex-col gap-sm">
|
||||
<h2
|
||||
id="contact-consortium-heading"
|
||||
className="label-md uppercase text-[var(--text-accent)]"
|
||||
>
|
||||
{copy.consortiumHeading}
|
||||
</h2>
|
||||
|
||||
{consortiumItems.length > 0 ? (
|
||||
<Accordion items={consortiumItems} defaultOpenIndex={null} />
|
||||
) : (
|
||||
<EmptyState message={copy.consortiumEmpty} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* EU VISIBILITY - the funding note the draft prints at the foot of
|
||||
this column. The emblem and the statement come from the shared
|
||||
badge, so the wording and the artwork stay contractual. */}
|
||||
<div
|
||||
className={`sh-reveal flex items-center gap-md rounded-card border border-[var(--border-subtle)] bg-[var(--surface-raised)] p-md ${ACCENT_EDGE}`}
|
||||
>
|
||||
<EuBadge
|
||||
size="sm"
|
||||
statement={t("site.euStatement")}
|
||||
locale={locale}
|
||||
surface="dark"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
{/* CONSORTIUM CONTACTS - "TBD" in the draft, so the CMS owns them. */}
|
||||
<PageSection aria-labelledby="contact-consortium-heading">
|
||||
<div className="flex flex-col gap-lg">
|
||||
<SectionHeader
|
||||
id="contact-consortium-heading"
|
||||
title={copy.consortiumHeading}
|
||||
/>
|
||||
|
||||
{consortiumContacts.length > 0 ? (
|
||||
<ul className="grid list-none grid-cols-1 gap-lg md:grid-cols-3">
|
||||
{consortiumContacts.map((contact) => (
|
||||
<li key={contact.id}>
|
||||
<InfoPanel title={contact.organisation}>
|
||||
<address className="flex flex-col gap-2xs not-italic">
|
||||
{contact.addressLine ? <span>{contact.addressLine}</span> : null}
|
||||
{contact.city || contact.country ? (
|
||||
<span>
|
||||
{[contact.city, contact.country]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</span>
|
||||
) : null}
|
||||
{contact.email ? (
|
||||
<a
|
||||
href={`mailto:${contact.email}`}
|
||||
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
>
|
||||
{contact.email}
|
||||
</a>
|
||||
) : null}
|
||||
{contact.phone ? (
|
||||
<a
|
||||
href={`tel:${contact.phone.replace(/\s+/g, "")}`}
|
||||
dir="ltr"
|
||||
className="text-[var(--text-link)] underline underline-offset-2 hover:text-[var(--text-link-hover)]"
|
||||
>
|
||||
{contact.phone}
|
||||
</a>
|
||||
) : null}
|
||||
</address>
|
||||
</InfoPanel>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState message={copy.consortiumEmpty} />
|
||||
)}
|
||||
</div>
|
||||
</PageSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user