/* ============================================================================
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-card, ...) - 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 (
{children}
);
}
return (
{children}
);
}
/* ---------------------------------------------------------------------------
ICON
------------------------------------------------------------------------ */
/** The assets exported from the Figma icon frame, served from public/. */
const CARD_ICON_SRC: Record = {
"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 (
);
}
/* ---------------------------------------------------------------------------
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 and the 4px
* corner the design drafts draw. `--elevation-card` is the drafts' resting
* shadow, which on the dark surface is none at all - the hairline does the
* separating, and the black is kept for the hover state in card.css.
*/
export function CardShell({
as = "article",
linked = false,
className,
children,
}: CardShellProps) {
const Tag = as;
return (
{children}
);
}
/* ---------------------------------------------------------------------------
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 (

{children}
);
}
/* ---------------------------------------------------------------------------
TEXT ATOMS
------------------------------------------------------------------------ */
/** The small accent kicker above a card title ("PROGRAMME", "DIALOGUE REPORT"). */
export function CardEyebrow({ children }: { children: ReactNode }) {
return {children}
;
}
/**
* 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 (
{label}
);
}
/* ---------------------------------------------------------------------------
UTIL
------------------------------------------------------------------------ */
/** Joins class names, dropping anything falsy. */
export function cx(...parts: Array): string {
return parts.filter(Boolean).join(" ");
}