Files
sharper.ly/apps/web/app/components/ui/Footer/Footer.tsx
T
IllusionandClaude Opus 5 2e920a96e7 Land the third design drop, seed the CMS, and retire the figures band
Three pieces of one session's work. They are in one commit because home.tsx
and _page-kit/copy.ts carry hunks from all three and splitting them by hand
would have meant committing intermediate states of those two files that were
never built or served.

THE THIRD DESIGN DROP

Only Home.dc.html and SiteFooter.dc.html differ from the second drop; the
other nine page drafts are byte-identical to what already ships.

The home page stops opening on a launch announcement dated to one day in
June. New headline, lead and primary call to action ("See open calls"), and
the launch band becomes "A nationwide project, delivered locally" -
launchEyebrow/Heading/Lead renamed approach*, LaunchShots renamed
GalleryShots, since the band is no longer about an event. Its Arabic reuses
the About page's already-approved wording for the coverage line and the body
heading; the new lead and headline are ar-draft like the rest of the Arabic
home copy and still want client sign-off.

Five motion classes in _page-kit/motion.css, tokens only, each with its RTL
form where the property has no logical one: sh-wipe (the headline uncovered
from the inline start), sh-pan (the hero picture on a view timeline), sh-zoom
(a picture closing in inside its frame), sh-rule-top (an accent rule drawn
across a hovered tile) and sh-marquee (the logo wall). sh-wipe fills
`backwards`, not `both`, so the clip is gone at rest - inset(0) would shave a
descender or an Arabic mark off a tight heading line - and it is removed
outright under prefers-reduced-motion, because a clip does not fade a
headline, it hides it.

The marquee carries each logo twice and slides exactly half the track, so its
spacing is a margin on the tile rather than a gap on the row: with a row gap
the seam lands half a gap short. The second half is aria-hidden with no link,
so the wall is neither read out nor tabbed through twice; it pauses on hover
and focus (WCAG 2.2.2) and unwraps to a plain row under reduced motion. Below
eight partners the wall stays a grid, because a half-track narrower than its
frame drifts a short row across empty space instead of looping.

SEEDING THE CMS

`pnpm seed` writes content through the document service. It opens no port, so
it runs happily beside `pnpm dev:cms`, and every write is matched on a slug
and written over the same document, so a second run changes nothing but the
fields the seed owns.

src/seed/approved.ts is transcribed from docs/ in both languages and is safe
to publish: site settings, the three consortium members, the topic taxonomy,
the five activity streams with their implementing partners, the launch
article in full, and the four pages. What docs/ withholds is not written -
no statistics, no project email, no Facebook URL, no consortium addresses,
all of which the source writes as bracketed placeholders. Two fields are
judgement calls and say so at the line: activityStatus, which the schema
requires and the source never states, and the launch article's year, which is
derived - the source says "Tuesday, June 30" and within December 2025 to
December 2027 that is 2026 and no other year.

src/seed/demo.ts is invented and kept apart three ways: opt-in behind --demo,
refused outright when NODE_ENV=production, and tagged in the data itself with
a demo- slug prefix that shows in every URL and is how --demo --clean finds
them again, with nothing remembered between runs.

Two things the Strapi 5 document service does not do by itself, both of which
leave Arabic rows behind that the next run then collides with on the unique
slug: delete({ documentId }) with no locale removes the DEFAULT locale only,
not the document, and findMany({ locale }) answers for one locale, so a
document whose English half has gone is invisible to a search in English. The
first --clean left six Arabic orphans and failed the next run partway.
deleteMatching now pools documentIds across both locales and deletes with
locale: '*'.

Knex aborts its pending pool acquisitions on teardown, and each abort rejects
a promise nothing awaits, so it arrives as an unhandled rejection rather than
as an error a try/catch would see. scripts/seed.js absorbs exactly that, for
the length of the shutdown only: a seed that worked must not report itself as
a seed that failed.

THE FIGURES BAND, AND THE BAND THAT REPLACED IT

The third draft deletes the figures band, so shared.stat and blocks.stats-grid
are deleted with it, home-page.stats is dropped, and blocks.stats-grid comes
out of all six dynamic zones. On the web side that takes CmsStat, StatsRow,
CountUpValue, StatStrip and Card.Stat - the last two were already dead, but
they existed only to draw figures. No approved document carries a number, so
there is now nowhere for one to be typed in, which is the point.

The "nationwide project, delivered locally" band was half editable and half
not: its pictures came from an image-gallery block down the Blocks dynamic
zone, and its words came from bundled fallback copy with no CMS field at all.
The new page.band-intro component (eyebrow, heading, lead, images) on
home-page.approach puts the whole band in one place in the admin. The page
still falls back to the bundled copy when the fields are empty, and still
reads an image-gallery block after the component's own images, so a gallery
built in Blocks keeps rendering.

VERIFIED

pnpm typecheck and both builds pass. Both rule greps are clean: no raw hex or
tier-1 token in components/ or routes/, no physical-direction utility in
apps/web/app.

Against a seeded CMS on the built server: / redirects 302 with
Vary: Accept-Language, and all ten sections plus the detail routes return 200
in both locales with lang/dir correct in the first bytes. The home page draws
the marquee from twelve partners, the hero pan, the three hovered card rules
and the status pill, and carries no figures band.

The seed is idempotent - two identical --demo runs give identical counts,
balanced across locales - and a clean/re-seed cycle leaves nothing behind.
The approach band was proved editable end to end: heading, eyebrow, lead and
images changed through the CMS all came through on /en while /ar stayed on
its own Arabic, then the seeded copy was restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 14:16:53 +02:00

332 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]" />
{/* The label never wraps under its own icon: "Follow us on Facebook" is
one line at every width, as the draft has it. */}
<span className="body-sm-bold whitespace-nowrap">{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={[]} />
========================================================================= */