initial commit

This commit is contained in:
2026-09-11 02:46:56 +02:00
commit 20b384039c
288 changed files with 46557 additions and 0 deletions
@@ -0,0 +1,97 @@
/* ============================================================================
Card.CTA - Figma 25:802 (720 x 141)
----------------------------------------------------------------------------
A call to action on the accent fill: eyebrow, headline, supporting line and
one outlined action. The action is the only interactive element, so this
card is never stretched over a link.
The panel carries data-surface="dark" because it is a dark green field
whatever the page around it is doing: inside it the focus ring, the hover
veil and every text role resolve for a dark background, on a light page too.
USAGE
-----
<Card.CTA
eyebrow="Ready to join us?"
headline="Empower your local community"
body="Apply now to join the upcoming civil dialogue summit and help draft public policies."
action={{ href: "/en/opportunities", label: "Learn more" }}
/>
// Arabic
<Card.CTA
headline="مكّن مجتمعك المحلي"
action={{ href: "/ar/opportunities", label: "اعرف المزيد" }}
/>
// External application form
<Card.CTA
headline="Call for expression of interest"
action={{ href: "https://forms.example.org/eoi", label: "Apply" }}
/>
========================================================================= */
import { Button } from "../Button";
import { CardLink, cx } from "./CardShell";
import type { CardLinkTarget, CardTitleElement } from "./types";
export interface CardCTAAction extends CardLinkTarget {
label: string;
}
export interface CardCTAProps {
headline: string;
action: CardCTAAction;
/** Small line above the headline ("Ready to join us?"). */
eyebrow?: string;
body?: string;
headlineAs?: CardTitleElement;
className?: string;
}
export function CardCTA({
headline,
action,
eyebrow,
body,
headlineAs = "h2",
className,
}: CardCTAProps) {
const Headline = headlineAs;
const { label, ...destination } = action;
return (
<section
data-surface="dark"
className={cx(
"sh-card relative flex flex-col gap-lg rounded-surface p-10",
"bg-surface-accent text-text-on-accent shadow-[var(--elevation-popover)]",
"sm:flex-row sm:items-center",
className,
)}
>
<div className="flex flex-1 flex-col gap-xs">
{eyebrow ? <p className="label-md">{eyebrow}</p> : null}
<Headline className="heading-h3">{headline}</Headline>
{/* Figma dims this line to 90%; left at full strength because white on
the accent green is already close to the AA floor. */}
{body ? <p className="body-md">{body}</p> : null}
</div>
{/* The outlined action is the project Button, not a look-alike: inside
this dark subtree its secondary variant already resolves to the white
stroke and white label the design asks for. CardLink keeps internal
hrefs routing client-side and external ones a plain anchor. */}
<Button
as={CardLink}
variant="secondary"
className="sh-card-cta-action shrink-0"
{...destination}
>
{label}
</Button>
</section>
);
}
@@ -0,0 +1,114 @@
/* ============================================================================
Card.Content - Figma 25:752 (360 x 372)
----------------------------------------------------------------------------
The news / blog / programme card: image, category badge, eyebrow, title,
excerpt and a "Read more" affordance. The whole card is one link.
USAGE
-----
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-3">
<Card.Content
as="li"
href="/en/news/civic-dialogue"
eyebrow="Programme"
badge="Rule of Law"
title="Strengthening Civic Dialogue"
excerpt="Supporting coordinated efforts among institutions, civil society,
and local actors to foster community resilience."
actionLabel="Read more"
image={{ src: "/media/dialogue.jpg", alt: "" }}
/>
</ul>
// Arabic - the copy is the only thing that changes, RTL mirrors itself.
<Card.Content
href="/ar/news/civic-dialogue"
title="تعزيز الحوار المدني"
actionLabel="اقرأ المزيد"
image={{ src: "/media/dialogue.jpg", alt: "" }}
/>
// Above the fold - skip lazy loading for the lead card only.
<Card.Content … image={{ src: "/media/lead.jpg", alt: "", priority: true }} />
========================================================================= */
import {
CardActionHint,
CardEyebrow,
CardLink,
CardMedia,
CardShell,
cx,
} from "./CardShell";
import type { CardElement, CardImage, CardTitleElement } from "./types";
export interface CardContentProps {
/** Where the whole card goes. */
href: string;
title: string;
/**
* Label of the "Read more" affordance. Required rather than defaulted so an
* Arabic page cannot silently ship the English string.
*/
actionLabel: string;
/** Accent kicker above the title, rendered uppercase. */
eyebrow?: string;
excerpt?: string;
/** Chip over the top-inline-start corner of the image ("Rule of Law"). */
badge?: string;
image?: CardImage;
as?: CardElement;
/** Heading level - match the surrounding page outline. */
titleAs?: CardTitleElement;
className?: string;
}
export function CardContent({
href,
title,
actionLabel,
eyebrow,
excerpt,
badge,
image,
as = "article",
titleAs = "h3",
className,
}: CardContentProps) {
const Title = titleAs;
return (
<CardShell as={as} linked className={cx("flex-col overflow-hidden", className)}>
{image ? (
<CardMedia image={image} className="aspect-[9/5] w-full">
{/* The chip sits on a scrim over the photo, so its label has to stay
light on both surfaces - text-on-accent is the only always-white
role, text-primary would go dark on the light surface. */}
{badge ? (
<span className="absolute start-md top-md rounded-control-lg bg-overlay-scrim px-xs py-2xs label-md text-text-on-accent backdrop-blur-[4px]">
{badge}
</span>
) : null}
</CardMedia>
) : null}
<div className="flex flex-col gap-md p-lg">
<div className="flex flex-col gap-xs">
{eyebrow ? <CardEyebrow>{eyebrow}</CardEyebrow> : null}
<Title className="sh-card-title body-lg-bold text-text-primary">
<CardLink href={href} className="sh-card-link">
{title}
</CardLink>
</Title>
{excerpt ? (
<p className="body-sm text-text-secondary line-clamp-3">{excerpt}</p>
) : null}
</div>
<CardActionHint label={actionLabel} />
</div>
</CardShell>
);
}
@@ -0,0 +1,104 @@
/* ============================================================================
Card.Horizontal - Figma 25:781 (720 x 200)
----------------------------------------------------------------------------
The listing row: a 240px image beside the text, image at the inline start so
it moves to the right in Arabic on its own. Stacks to a single column below
the sm breakpoint. Like Card.Content, the whole row is one link.
USAGE
-----
<ul className="flex flex-col gap-lg">
<Card.Horizontal
as="li"
href="/en/resources/governance-systems"
eyebrow="Dialogue report"
title="Strengthening Governance Systems"
excerpt="Our annual deep-dive reports focus on structural coordination,
bringing diverse multi-stakeholder views into policy discussions."
actionLabel="Read full report"
image={{ src: "/media/roundtable.jpg", alt: "" }}
/>
</ul>
// External resource - rendered as a plain anchor, not a client-side route.
<Card.Horizontal
href="https://example.org/report.pdf"
title="Annual review 2026"
actionLabel="Download"
/>
========================================================================= */
import {
CardActionHint,
CardEyebrow,
CardLink,
CardMedia,
CardShell,
cx,
} from "./CardShell";
import type { CardElement, CardImage, CardTitleElement } from "./types";
export interface CardHorizontalProps {
href: string;
title: string;
/** Label of the "Read full report" affordance - required, so Arabic cannot inherit English. */
actionLabel: string;
eyebrow?: string;
excerpt?: string;
image?: CardImage;
as?: CardElement;
titleAs?: CardTitleElement;
className?: string;
}
export function CardHorizontal({
href,
title,
actionLabel,
eyebrow,
excerpt,
image,
as = "article",
titleAs = "h3",
className,
}: CardHorizontalProps) {
const Title = titleAs;
return (
<CardShell
as={as}
linked
className={cx(
"flex-col overflow-hidden sm:flex-row sm:items-stretch",
className,
)}
>
{/* Stacked: the ratio reserves the height. Side by side: a fixed 240px
column that stretches to whatever the text column needs. */}
{image ? (
<CardMedia
image={image}
className="aspect-[6/5] w-full sm:aspect-auto sm:w-60 sm:shrink-0"
/>
) : null}
<div className="flex flex-1 flex-col justify-between gap-md p-lg">
<div className="flex flex-col gap-xs">
{eyebrow ? <CardEyebrow>{eyebrow}</CardEyebrow> : null}
<Title className="sh-card-title body-lg-bold text-text-primary">
<CardLink href={href} className="sh-card-link">
{title}
</CardLink>
</Title>
{excerpt ? (
<p className="body-sm text-text-secondary line-clamp-2">{excerpt}</p>
) : null}
</div>
<CardActionHint label={actionLabel} />
</div>
</CardShell>
);
}
@@ -0,0 +1,238 @@
/* ============================================================================
CARD - shared building blocks
----------------------------------------------------------------------------
Every card in this directory is assembled from the pieces below, so the
surface, radius, border, elevation and link behaviour are decided once.
STYLING RULE for this whole directory:
* colour and radius come from the tier-2 Tailwind bridge
(bg-surface-raised, text-text-accent, border-border-subtle,
rounded-surface, ...) - never a hex, never a tier-1 primitive;
* tokens without a bridge utility are spent as arbitrary values,
e.g. shadow-[var(--elevation-card)];
* spacing uses the named scale (p-lg = 24, gap-md = 16) so the Figma
numbers stay readable;
* everything directional uses logical utilities (ps-/pe-/start-/end-)
so the Arabic build mirrors without a second stylesheet.
Nothing here is surface-aware by hand: the same markup is correct on
[data-surface="dark"] and [data-surface="light"] because every colour is a
role the surface re-points.
========================================================================= */
import type { CSSProperties, ReactElement, ReactNode } from "react";
import { Link } from "react-router";
import "./card.css";
import type {
CardElement,
CardIconName,
CardImage,
CardLinkTarget,
} from "./types";
/* ---------------------------------------------------------------------------
LINK
------------------------------------------------------------------------ */
/** Anything with a scheme, a protocol-relative host or a fragment is not a route. */
const NON_ROUTE_HREF = /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i;
/**
* React Router sits in the hoisted root of this workspace, where it resolves
* its React types from the @types/react 18 that Strapi pulls in, so its props
* are declared against a ReactNode that is not ours (no bigint, and a
* ReactPortal that still requires children). Re-declaring the component once
* here keeps every call site below honest instead of casting at each of them.
* Delete this alias when the workspace resolves to a single @types/react.
*/
const RouterLink = Link as unknown as (props: {
to: string;
className?: string;
target?: string;
rel?: string;
children: ReactNode;
}) => ReactElement;
export interface CardLinkProps extends CardLinkTarget {
className?: string;
children: ReactNode;
}
/**
* Routes internal hrefs through React Router so navigation stays client-side,
* and falls back to a plain anchor for external links, mailto: and fragments.
*/
export function CardLink({ href, className, children, ...rest }: CardLinkProps) {
if (NON_ROUTE_HREF.test(href)) {
return (
<a href={href} className={className} {...rest}>
{children}
</a>
);
}
return (
<RouterLink to={href} className={className} {...rest}>
{children}
</RouterLink>
);
}
/* ---------------------------------------------------------------------------
ICON
------------------------------------------------------------------------ */
/** The assets exported from the Figma icon frame, served from public/. */
const CARD_ICON_SRC: Record<CardIconName, string> = {
"arrow-right": "url('/assets/icons/arrow-right.svg')",
"arrow-up": "url('/assets/icons/arrow-up.svg')",
linkedin: "url('/assets/icons/linkedin.svg')",
mail: "url('/assets/icons/mail.svg')",
globe: "url('/assets/icons/globe.svg')",
};
export interface CardIconProps {
name: CardIconName;
/** Designed box in px - 14 for the 14x14 icons, 10 for the trend arrow. */
size: number;
/** Mirror in RTL. True for the horizontal arrow, false for the vertical one. */
directional?: boolean;
className?: string;
}
/**
* Draws an exported SVG as a mask over a currentColor fill, so the icon keeps
* the geometry Figma exported while taking its colour from the token in force.
* Always decorative - the adjacent text carries the meaning.
*/
export function CardIcon({ name, size, directional, className }: CardIconProps) {
return (
<span
aria-hidden="true"
className={cx(
"sh-card-icon",
directional && "sh-card-icon--directional",
className,
)}
style={
{
"--sh-card-icon-src": CARD_ICON_SRC[name],
width: size,
height: size,
} as CSSProperties
}
/>
);
}
/* ---------------------------------------------------------------------------
SHELL
------------------------------------------------------------------------ */
export interface CardShellProps {
/** `li` inside a list, `section` for a landmark, `article` by default. */
as?: CardElement;
/**
* The card contains a stretched link. Adds the hover lift and lets the
* ::after overlay in card.css position against the card.
*/
linked?: boolean;
className?: string;
children: ReactNode;
}
/**
* The surface every card shares: raised fill, hairline border, 12px corner
* (Figma radius/lg) and the card elevation.
*/
export function CardShell({
as = "article",
linked = false,
className,
children,
}: CardShellProps) {
const Tag = as;
return (
<Tag
className={cx(
"sh-card",
linked && "sh-card--linked",
"relative flex bg-surface-raised text-text-primary",
"rounded-surface border border-border-subtle shadow-[var(--elevation-card)]",
className,
)}
>
{children}
</Tag>
);
}
/* ---------------------------------------------------------------------------
MEDIA
------------------------------------------------------------------------ */
export interface CardMediaProps {
image: CardImage;
/** Sets the box: an aspect-ratio utility, a fixed size, or both per breakpoint. */
className?: string;
/** Overlays that sit on the image, e.g. the category badge. */
children?: ReactNode;
}
/**
* A media box whose height is decided by the wrapper, never by the file, so
* the layout is stable before the image loads.
*/
export function CardMedia({ image, className, children }: CardMediaProps) {
return (
<div className={cx("sh-card-media relative overflow-hidden", className)}>
<img
src={image.src}
srcSet={image.srcSet}
sizes={image.sizes}
alt={image.alt}
loading={image.priority ? "eager" : "lazy"}
fetchPriority={image.priority ? "high" : undefined}
decoding="async"
className="absolute inset-0 block size-full object-cover"
/>
{children}
</div>
);
}
/* ---------------------------------------------------------------------------
TEXT ATOMS
------------------------------------------------------------------------ */
/** The small accent kicker above a card title ("PROGRAMME", "DIALOGUE REPORT"). */
export function CardEyebrow({ children }: { children: ReactNode }) {
return <p className="label-md uppercase text-text-accent">{children}</p>;
}
/**
* The "Read more ->" affordance. Decorative: the stretched title link already
* carries the destination, so announcing this again would just be noise.
*/
export function CardActionHint({ label }: { label: string }) {
return (
<p
aria-hidden="true"
className="inline-flex items-center gap-xs body-sm-bold text-text-accent"
>
{label}
<CardIcon name="arrow-right" size={14} directional />
</p>
);
}
/* ---------------------------------------------------------------------------
UTIL
------------------------------------------------------------------------ */
/** Joins class names, dropping anything falsy. */
export function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
@@ -0,0 +1,106 @@
/* ============================================================================
Card.Stat - Figma 25:792 (260 x 166)
----------------------------------------------------------------------------
The figure card used by the home page stats strip: a large accent number,
an optional trend chip, the label it counts, a rule and a caption. Never a
link - a number is not a destination.
USAGE
-----
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-4">
<Card.Stat
as="li"
value="2,450"
label="Participants reached"
caption="Cumulative regional outreach, updated each quarter."
trend={{ label: "12%", direction: "up", description: "up 12% on last quarter" }}
/>
<Card.Stat as="li" value="18" label="Municipalities engaged" />
</ul>
// Arabic - Eastern Arabic numerals belong in the value string itself.
<Card.Stat value="٢٬٤٥٠" label="عدد المشاركين" />
========================================================================= */
import { CardIcon, CardShell, cx } from "./CardShell";
import type { CardElement } from "./types";
export interface CardStatTrend {
/** Visible text, e.g. "12%". */
label: string;
/** Up is the accent fill, down the danger fill. Defaults to up. */
direction?: "up" | "down";
/**
* Spoken instead of the bare figure, e.g. "up 12% on last quarter". The
* arrow is decorative, so without this a screen reader hears only "12%".
*/
description?: string;
}
export interface CardStatProps {
/** Pre-formatted for the locale - the card never formats a number itself. */
value: string;
label: string;
caption?: string;
trend?: CardStatTrend;
as?: CardElement;
className?: string;
}
export function CardStat({
value,
label,
caption,
trend,
as = "article",
className,
}: CardStatProps) {
const isDown = trend?.direction === "down";
return (
<CardShell
as={as}
className={cx("flex-col items-start gap-md p-lg", className)}
>
<div className="flex w-full items-center justify-between gap-sm">
<p className="display-sm tabular-nums text-text-accent">{value}</p>
{trend ? (
/* Figma paints the chip label in text/accent on a background/accent
fill - green on green, invisible. Kept as the accent fill with the
always-white on-accent label, and the danger fill when the figure
is falling. */
<span
className={cx(
"inline-flex shrink-0 items-center gap-2xs rounded-control-lg px-xs py-2xs",
"label-md text-text-on-accent",
isDown ? "bg-surface-danger" : "bg-surface-accent",
)}
>
<CardIcon
name="arrow-up"
size={10}
className={isDown ? "rotate-180" : undefined}
/>
{trend.description ? (
<>
<span aria-hidden="true">{trend.label}</span>
<span className="sr-only">{trend.description}</span>
</>
) : (
trend.label
)}
</span>
) : null}
</div>
<p className="body-md-bold text-text-primary">{label}</p>
<div aria-hidden="true" className="w-full border-t border-border-subtle" />
{caption ? (
<p className="body-sm text-text-secondary">{caption}</p>
) : null}
</CardShell>
);
}
@@ -0,0 +1,140 @@
/* ============================================================================
Card.Team - Figma 25:765 (280 x 295)
----------------------------------------------------------------------------
Portrait in an accent frame, name, role, affiliation and up to three social
links. The social links are real links, so the card itself is only stretched
over a link when `href` is given - and the links are lifted above the
overlay so they stay clickable.
USAGE
-----
<ul className="grid gap-lg sm:grid-cols-2 lg:grid-cols-4">
<Card.Team
as="li"
name="Dr. Helen Vance"
role="Chief Programme Director"
affiliation="SHARP-ER Governance Initiative"
portrait={{ src: "/media/team/helen-vance.jpg", alt: "" }}
socials={[
{ kind: "linkedin", href: "https://www.linkedin.com/in/example" },
{ kind: "mail", href: "mailto:helen.vance@example.org", label: "Email" },
{ kind: "website", href: "https://example.org", label: "Website" },
]}
/>
</ul>
// Optional bio page - the whole card becomes clickable, socials still work.
<Card.Team href="/en/about/helen-vance" name="Dr. Helen Vance" … />
========================================================================= */
import { CardLink, CardMedia, CardShell, CardIcon, cx } from "./CardShell";
import type { CardElement, CardIconName, CardImage, CardTitleElement } from "./types";
export type CardTeamSocialKind = "linkedin" | "mail" | "website";
export interface CardTeamSocial {
kind: CardTeamSocialKind;
href: string;
/**
* Accessible name of the link. Defaults to the platform name; pass a
* translated string on Arabic pages ("البريد الإلكتروني", "الموقع").
*/
label?: string;
}
export interface CardTeamProps {
name: string;
role?: string;
/** Team, unit or initiative the person belongs to. */
affiliation?: string;
portrait?: CardImage;
socials?: CardTeamSocial[];
/** Optional bio page. Given, the whole card links to it. */
href?: string;
as?: CardElement;
titleAs?: CardTitleElement;
className?: string;
}
const SOCIAL_ICON: Record<CardTeamSocialKind, CardIconName> = {
linkedin: "linkedin",
mail: "mail",
website: "globe",
};
const SOCIAL_LABEL: Record<CardTeamSocialKind, string> = {
linkedin: "LinkedIn",
mail: "Email",
website: "Website",
};
export function CardTeam({
name,
role,
affiliation,
portrait,
socials,
href,
as = "article",
titleAs = "h3",
className,
}: CardTeamProps) {
const Title = titleAs;
return (
<CardShell
as={as}
linked={Boolean(href)}
className={cx("flex-col items-center gap-5 p-xl text-center", className)}
>
{/* 96px portrait inside the 4px accent frame, exactly as drawn. */}
{portrait ? (
<CardMedia
image={portrait}
className="size-24 shrink-0 rounded-surface border-4 border-border-accent"
/>
) : null}
<div className="flex w-full flex-col gap-xs">
<Title className="sh-card-title body-md-bold text-text-primary">
{href ? (
<CardLink href={href} className="sh-card-link">
{name}
</CardLink>
) : (
name
)}
</Title>
{role ? <p className="body-sm text-text-secondary">{role}</p> : null}
{affiliation ? (
<p className="body-sm text-text-accent">{affiliation}</p>
) : null}
</div>
{socials && socials.length > 0 ? (
/* sh-card-above keeps these clickable through the stretched-link
overlay when the card as a whole is a link. */
<ul className="sh-card-above flex items-start justify-center gap-sm">
{socials.map((social) => (
<li key={`${social.kind}-${social.href}`}>
{/* Figma strokes these icons in text/accent on a background/accent
chip - green on green, invisible. Painted with the always-white
on-accent role instead. */}
<CardLink
href={social.href}
className="flex size-8 items-center justify-center rounded-surface bg-surface-accent text-text-on-accent"
>
<CardIcon name={SOCIAL_ICON[social.kind]} size={14} />
<span className="sr-only">
{`${social.label ?? SOCIAL_LABEL[social.kind]} - ${name}`}
</span>
</CardLink>
</li>
))}
</ul>
) : null}
</CardShell>
);
}
+129
View File
@@ -0,0 +1,129 @@
/* ============================================================================
CARD - mechanics that cannot be expressed as a utility
----------------------------------------------------------------------------
Everything token-driven lives in the TSX as a tier-2 utility
(bg-surface-raised, text-text-accent, rounded-surface, ...). This file only
holds the three things a utility cannot do:
1. paint an exported SVG asset in the current token colour (mask),
2. the whole-card link overlay + its focus ring,
3. the hover transitions, which need a duration/easing token pair.
Class names are prefixed `sh-card` because this file is imported by the
component rather than by app/app.css, so it is not scoped by anything else.
Everything lives in @layer components so a Tailwind utility passed through
`className` (`rounded-surface`, `text-text-secondary`, ...) still wins.
The layer statement is a defensive no-op when app.css has already declared
the same order, and makes this file correct on its own if it ever loads first.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
/* ---------------------------------------------------------------------------
ICONS
The SVGs exported from Figma (public/assets/icons/*.svg) have the brand
green baked in as stroke="#008764", which would be wrong on the dark surface
and inside the accent chips. Drawing the asset as a mask over a currentColor
fill keeps the exact exported geometry while the colour comes from whichever
semantic token the parent set. Size is set per instance so each icon keeps
its designed box (14px, or 10px for the stat trend arrow).
------------------------------------------------------------------------ */
.sh-card-icon {
display: block;
flex: none;
background-color: currentColor;
-webkit-mask-image: var(--sh-card-icon-src);
mask-image: var(--sh-card-icon-src);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-size: contain;
mask-size: contain;
}
/* Arrows that mean "forward" point the other way in Arabic. Only the
horizontal arrow mirrors - the stat trend arrow is vertical. */
[dir="rtl"] .sh-card-icon--directional {
transform: scaleX(-1);
}
/* ---------------------------------------------------------------------------
WHOLE-CARD LINK
The anchor around the title stretches over the card with ::after, so the
entire card is clickable while the title stays the accessible name and no
interactive element is nested inside another.
z-index 1 / 2 are structural, not design values: 1 is the overlay, 2 lifts
anything that has to stay clickable above it (the team card's social links).
------------------------------------------------------------------------ */
.sh-card-link::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
}
.sh-card-above {
position: relative;
z-index: 2;
}
/* The ring belongs around the card, not around the inline title text. Where
:has() is unavailable the anchor keeps the global :focus-visible ring from
app.css, so focus is never invisible. */
@supports selector(:has(*)) {
.sh-card-link:focus-visible {
outline: none;
}
.sh-card:has(.sh-card-link:focus-visible) {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
}
/* ---------------------------------------------------------------------------
HOVER / FOCUS AFFORDANCES
------------------------------------------------------------------------ */
.sh-card--linked {
transition:
box-shadow var(--motion-duration-base) var(--motion-ease-standard),
border-color var(--motion-duration-base) var(--motion-ease-standard);
}
.sh-card--linked:hover {
border-color: var(--border-default);
box-shadow: var(--elevation-popover);
}
.sh-card--linked:hover .sh-card-title,
.sh-card--linked:has(.sh-card-link:focus-visible) .sh-card-title {
text-decoration: underline;
text-underline-offset: 2px;
}
.sh-card-media img {
transition: transform var(--motion-duration-slow) var(--motion-ease-standard);
}
.sh-card--linked:hover .sh-card-media img {
transform: scale(1.04);
}
/* app.css already neutralises transition durations under reduced motion, but a
transform still lands instantly - drop it entirely. */
@media (prefers-reduced-motion: reduce) {
.sh-card--linked:hover .sh-card-media img {
transform: none;
}
}
/* The CTA sits on the accent fill, where the green focus ring has almost no
contrast. Inside its data-surface="dark" subtree the inverse ring is white. */
.sh-card-cta-action:focus-visible {
outline-color: var(--focus-ring-color-inverse);
}
}
+71
View File
@@ -0,0 +1,71 @@
/* ============================================================================
CARD - Figma frame 25:809
----------------------------------------------------------------------------
Five card types that share one surface, one link mechanism and one icon
strategy. Import the namespace for readable markup:
import { Card } from "~/components/ui/Card";
<Card.Content … /> <Card.Team … /> <Card.Horizontal … />
<Card.Stat … /> <Card.CTA … />
or the components directly when you want the narrowest import:
import { CardStat } from "~/components/ui/Card";
Every card is correct on both surfaces without a prop: colour comes only
from tier-2 roles, which [data-surface="dark"] and [data-surface="light"]
re-point. See CardShell.tsx for the styling rules this directory follows.
========================================================================= */
export { CardContent } from "./CardContent";
export { CardTeam } from "./CardTeam";
export { CardHorizontal } from "./CardHorizontal";
export { CardStat } from "./CardStat";
export { CardCTA } from "./CardCTA";
export {
CardShell,
CardMedia,
CardIcon,
CardLink,
CardEyebrow,
CardActionHint,
} from "./CardShell";
export type { CardContentProps } from "./CardContent";
export type {
CardTeamProps,
CardTeamSocial,
CardTeamSocialKind,
} from "./CardTeam";
export type { CardHorizontalProps } from "./CardHorizontal";
export type { CardStatProps, CardStatTrend } from "./CardStat";
export type { CardCTAProps, CardCTAAction } from "./CardCTA";
export type {
CardShellProps,
CardMediaProps,
CardIconProps,
CardLinkProps,
} from "./CardShell";
export type {
CardElement,
CardIconName,
CardImage,
CardLinkTarget,
CardTitleElement,
} from "./types";
import { CardContent } from "./CardContent";
import { CardTeam } from "./CardTeam";
import { CardHorizontal } from "./CardHorizontal";
import { CardStat } from "./CardStat";
import { CardCTA } from "./CardCTA";
/** The card family, namespaced: Card.Content, Card.Team, Card.Horizontal, Card.Stat, Card.CTA. */
export const Card = {
Content: CardContent,
Team: CardTeam,
Horizontal: CardHorizontal,
Stat: CardStat,
CTA: CardCTA,
} as const;
+42
View File
@@ -0,0 +1,42 @@
/* ============================================================================
CARD - shared prop types
========================================================================= */
/** Elements a card shell is allowed to render as. `li` for grids and lists. */
export type CardElement = "article" | "div" | "li" | "section";
/** Heading level for a card title - pick the one that fits the page outline. */
export type CardTitleElement = "h2" | "h3" | "h4";
/**
* An image in a card.
*
* The card fixes the aspect ratio of every media box, so the space is reserved
* before the file arrives and nothing shifts. `priority` is for the rare card
* that is above the fold; everything else stays lazy.
*/
export interface CardImage {
src: string;
/** Pass "" for a purely decorative image so it is skipped by screen readers. */
alt: string;
srcSet?: string;
sizes?: string;
/** Above the fold: load eagerly at high priority instead of lazily. */
priority?: boolean;
}
/** A destination. Internal paths route client-side, everything else is a plain anchor. */
export interface CardLinkTarget {
/** `/en/news/launch` routes client-side; `https://…`, `mailto:…`, `#…` do not. */
href: string;
target?: string;
rel?: string;
}
/** The five icons the card family uses, each an asset exported from Figma. */
export type CardIconName =
| "arrow-right"
| "arrow-up"
| "linkedin"
| "mail"
| "globe";