Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions frontend/src/design-system/primitives/Card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { forwardRef, type CSSProperties, type ElementType, type HTMLAttributes, type Ref } from 'react';

Check warning on line 1 in frontend/src/design-system/primitives/Card.tsx

View workflow job for this annotation

GitHub Actions / frontend-build-and-test

'Ref' is defined but never used. Allowed unused vars must match /^_/u

Check warning on line 1 in frontend/src/design-system/primitives/Card.tsx

View workflow job for this annotation

GitHub Actions / frontend-build-and-test

'ElementType' is defined but never used. Allowed unused vars must match /^_/u
import type { SpacingKey } from '../tokens';
import { Cluster, type ClusterJustify } from './Cluster';
import { Heading, type HeadingLevel } from './Heading';
import { Stack } from './Stack';
import { Surface, type SurfaceElevation, type SurfaceProps, type SurfaceRadius, type SurfaceVariant } from './Surface';
import { Text, type TextSize, type TextTone } from './Text';
import { space } from './utils';

/* ──────────────────────────────────────────────────────────────────────
* Card — pre-configured Surface with sensible defaults for the
* dashboard / form panel use case.
*
* Defaults: variant=subtle, radius=xl, padding=5, bordered, no shadow.
* Overrides: every Surface prop is forwarded — pass `padding`, `radius`,
* `elevation`, `interactive` to fine-tune.
*
* Internally the Card lays its children out as a vertical Stack with
* gap=4, so the sub-components below compose naturally without their
* own margins.
* ────────────────────────────────────────────────────────────────────── */

export interface CardProps extends Omit<SurfaceProps, 'p'> {
/** Internal vertical gap between sub-sections. Default `'4'` (16 px). */
gap?: SpacingKey;
/** Padding token. Default `'5'` (20 px). */
p?: SpacingKey;
}

export const Card = forwardRef<HTMLElement, CardProps>(function Card(
{
variant = 'subtle' as SurfaceVariant,
radius = 'xl' as SurfaceRadius,
elevation = 0 as SurfaceElevation,
bordered = true,
interactive = false,
p = '5',
gap = '4',
style, children, ...rest
},
ref,
) {
const inline: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: space(gap),
...style,
};
return (
<Surface
ref={ref}
variant={variant}
radius={radius}
elevation={elevation}
bordered={bordered}
interactive={interactive}
p={p}
style={inline}
{...rest}
>
{children}
</Surface>
);
});

/* ─── CardHeader ─────────────────────────────────────────────────────── */

export interface CardHeaderProps extends HTMLAttributes<HTMLElement> {
as?: 'header' | 'div';
/** Vertical gap between title and description. Default `'1'` (4 px). */
gap?: SpacingKey;
}

export function CardHeader({ as = 'header', gap = '1', children, ...rest }: CardHeaderProps) {
return (
<Stack as={as} gap={gap} {...rest}>
{children}
</Stack>
);
}

/* ─── CardTitle ──────────────────────────────────────────────────────── */

export interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {
/** Visual / semantic heading level. Default `3`. */
level?: HeadingLevel;
}

export function CardTitle({ level = 3, children, ...rest }: CardTitleProps) {
return (
<Heading level={level} {...rest}>
{children}
</Heading>
);
}

/* ─── CardDescription ────────────────────────────────────────────────── */

export interface CardDescriptionProps extends HTMLAttributes<HTMLParagraphElement> {
/** Text size token. Default `'sm'`. */
size?: TextSize;
/** Text tone token. Default `'secondary'`. */
tone?: TextTone;
}

export function CardDescription({
size = 'sm',
tone = 'secondary',
children,
...rest
}: CardDescriptionProps) {
return (
<Text as="p" size={size} tone={tone} {...rest}>
{children}
</Text>
);
}

/* ─── CardContent ────────────────────────────────────────────────────── */

export interface CardContentProps extends HTMLAttributes<HTMLElement> {
as?: 'div' | 'section';
/** Vertical gap between content blocks. Default `'3'` (12 px). */
gap?: SpacingKey;
}

export function CardContent({ as = 'div', gap = '3', children, ...rest }: CardContentProps) {
return (
<Stack as={as} gap={gap} {...rest}>
{children}
</Stack>
);
}

/* ─── CardFooter ─────────────────────────────────────────────────────── */

export interface CardFooterProps extends HTMLAttributes<HTMLElement> {
as?: 'footer' | 'div';
/** Horizontal gap between actions. Default `'2'` (8 px). */
gap?: SpacingKey;
/** Justification for the action row. Default `'end'`. */
justify?: ClusterJustify;
}

export function CardFooter({
as = 'footer',
gap = '2',
justify = 'end',
children,
...rest
}: CardFooterProps) {
return (
<Cluster as={as} gap={gap} justify={justify} align="center" {...rest}>
{children}
</Cluster>
);
}
29 changes: 29 additions & 0 deletions frontend/src/design-system/primitives/Surface.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/* Surface — token-only adapter layer.
*
* The base styling is supplied via inline `style` (driven by tokens) so
* that variant overrides remain visible in DevTools. This module only
* provides the *interactive* state, which needs `:hover` / `:focus-visible`
* pseudo-classes that cannot be expressed inline.
*/

.surface {
transition:
background-color 120ms ease,
border-color 120ms ease,
box-shadow 160ms ease;
}

.surface[data-interactive='true'] {
cursor: pointer;
}

.surface[data-interactive='true']:hover {
background: var(--bg-hover);
border-color: var(--border-hover);
}

.surface[data-interactive='true']:focus-visible {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--border-focus) 35%, transparent);
}
136 changes: 136 additions & 0 deletions frontend/src/design-system/primitives/Surface.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { forwardRef, type CSSProperties, type ElementType, type HTMLAttributes, type Ref } from 'react';
import type { SpacingKey } from '../tokens';
import { cx, space } from './utils';
import s from './Surface.module.css';

type SurfaceAs =
| 'div'
| 'section'
| 'article'
| 'aside'
| 'header'
| 'footer'
| 'main'
| 'nav';

/**
* Visual surface variant — drives the background token only.
*
* - `subtle` → `--card-bg` (translucent over the page; default for cards)
* - `raised` → `--bg-elevated` (opaque step above the page)
* - `flat` → transparent (use when only border/padding is wanted)
*/
export type SurfaceVariant = 'subtle' | 'raised' | 'flat';

/**
* Border radius scale, mapped 1:1 onto the `--radius-*` tokens.
*/
export type SurfaceRadius = 'sm' | 'md' | 'lg' | 'xl' | '2xl';

/**
* Elevation step — maps onto the `--shadow-N` tokens. `0` is no shadow.
*/
export type SurfaceElevation = 0 | 1 | 2 | 3 | 4;

const VARIANT_BG: Record<SurfaceVariant, string | undefined> = {
subtle: 'var(--card-bg)',
raised: 'var(--bg-elevated)',
flat: undefined,
};

const RADIUS: Record<SurfaceRadius, string> = {
sm: 'var(--radius-sm)',
md: 'var(--radius-md)',
lg: 'var(--radius-lg)',
xl: 'var(--radius-xl)',
'2xl': 'var(--radius-2xl)',
};

const ELEVATION: Record<SurfaceElevation, string> = {
0: 'var(--shadow-0)',
1: 'var(--shadow-1)',
2: 'var(--shadow-2)',
3: 'var(--shadow-3)',
4: 'var(--shadow-4)',
};

export interface SurfaceProps extends HTMLAttributes<HTMLElement> {
as?: SurfaceAs;
/** Background variant. Default `'subtle'`. */
variant?: SurfaceVariant;
/** When true, draws a 1 px `--border` outline. Default `true`. */
bordered?: boolean;
/** Border radius token. Default `'lg'`. */
radius?: SurfaceRadius;
/** Drop-shadow elevation token. Default `0` (none). */
elevation?: SurfaceElevation;
/** When true, applies hover/focus background and border tokens. */
interactive?: boolean;
/** Padding on all sides. */
p?: SpacingKey;
/** Horizontal padding (left + right). */
px?: SpacingKey;
/** Vertical padding (top + bottom). */
py?: SpacingKey;
pt?: SpacingKey;
pr?: SpacingKey;
pb?: SpacingKey;
pl?: SpacingKey;
}

/**
* Surface — the design system's primitive container.
*
* Encapsulates the four properties that distinguish a "panel" in this
* design language: **background**, **border**, **radius**, and **elevation**
* — every value sourced from a token, no magic numbers.
*
* Use `Surface` when you need a styled container without card semantics
* (e.g. an inline callout, a sidebar region). Prefer `Card` for the
* standard dashboard / form surface, which is just a `Surface` with
* sensible card defaults.
*/
export const Surface = forwardRef<HTMLElement, SurfaceProps>(function Surface(
{
as = 'div',
variant = 'subtle',
bordered = true,
radius = 'lg',
elevation = 0,
interactive = false,
p, px, py, pt, pr, pb, pl,
className, style, children, ...rest
},
ref,
) {
const Component = as as ElementType;
const inline: CSSProperties = {
...style,
background: VARIANT_BG[variant],
border: bordered ? '1px solid var(--border)' : undefined,
borderRadius: RADIUS[radius],
boxShadow: ELEVATION[elevation],
};

if (p !== undefined) inline.padding = space(p);
if (px !== undefined) { inline.paddingLeft = space(px); inline.paddingRight = space(px); }
if (py !== undefined) { inline.paddingTop = space(py); inline.paddingBottom = space(py); }
if (pt !== undefined) inline.paddingTop = space(pt);
if (pr !== undefined) inline.paddingRight = space(pr);
if (pb !== undefined) inline.paddingBottom = space(pb);
if (pl !== undefined) inline.paddingLeft = space(pl);

return (
<Component
ref={ref as Ref<HTMLElement>}
className={cx(s.surface, className)}
data-variant={variant}
data-interactive={interactive ? 'true' : undefined}
data-bordered={bordered ? 'true' : 'false'}
style={inline}
{...rest}
>
{children}
</Component>
);
});
Loading
Loading