diff --git a/frontend/src/design-system/primitives/Card.tsx b/frontend/src/design-system/primitives/Card.tsx new file mode 100644 index 00000000..7ede1140 --- /dev/null +++ b/frontend/src/design-system/primitives/Card.tsx @@ -0,0 +1,157 @@ +import { forwardRef, type CSSProperties, type ElementType, type HTMLAttributes, type Ref } from 'react'; +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 { + /** Internal vertical gap between sub-sections. Default `'4'` (16 px). */ + gap?: SpacingKey; + /** Padding token. Default `'5'` (20 px). */ + p?: SpacingKey; +} + +export const Card = forwardRef(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 ( + + {children} + + ); +}); + +/* ─── CardHeader ─────────────────────────────────────────────────────── */ + +export interface CardHeaderProps extends HTMLAttributes { + 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 ( + + {children} + + ); +} + +/* ─── CardTitle ──────────────────────────────────────────────────────── */ + +export interface CardTitleProps extends HTMLAttributes { + /** Visual / semantic heading level. Default `3`. */ + level?: HeadingLevel; +} + +export function CardTitle({ level = 3, children, ...rest }: CardTitleProps) { + return ( + + {children} + + ); +} + +/* ─── CardDescription ────────────────────────────────────────────────── */ + +export interface CardDescriptionProps extends HTMLAttributes { + /** 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 ( + + {children} + + ); +} + +/* ─── CardContent ────────────────────────────────────────────────────── */ + +export interface CardContentProps extends HTMLAttributes { + 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 ( + + {children} + + ); +} + +/* ─── CardFooter ─────────────────────────────────────────────────────── */ + +export interface CardFooterProps extends HTMLAttributes { + 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 ( + + {children} + + ); +} diff --git a/frontend/src/design-system/primitives/Surface.module.css b/frontend/src/design-system/primitives/Surface.module.css new file mode 100644 index 00000000..8b565d2d --- /dev/null +++ b/frontend/src/design-system/primitives/Surface.module.css @@ -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); +} diff --git a/frontend/src/design-system/primitives/Surface.tsx b/frontend/src/design-system/primitives/Surface.tsx new file mode 100644 index 00000000..e74fea96 --- /dev/null +++ b/frontend/src/design-system/primitives/Surface.tsx @@ -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 = { + subtle: 'var(--card-bg)', + raised: 'var(--bg-elevated)', + flat: undefined, +}; + +const RADIUS: Record = { + sm: 'var(--radius-sm)', + md: 'var(--radius-md)', + lg: 'var(--radius-lg)', + xl: 'var(--radius-xl)', + '2xl': 'var(--radius-2xl)', +}; + +const ELEVATION: Record = { + 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 { + 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(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 ( + } + className={cx(s.surface, className)} + data-variant={variant} + data-interactive={interactive ? 'true' : undefined} + data-bordered={bordered ? 'true' : 'false'} + style={inline} + {...rest} + > + {children} + + ); +}); diff --git a/frontend/src/design-system/primitives/__tests__/Card.test.tsx b/frontend/src/design-system/primitives/__tests__/Card.test.tsx new file mode 100644 index 00000000..7df33251 --- /dev/null +++ b/frontend/src/design-system/primitives/__tests__/Card.test.tsx @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, +} from '../Card'; + +const styleOf = (el: Element) => el.getAttribute('style') ?? ''; + +describe('Card', () => { + it('renders a
by default', () => { + const { container } = render(x); + expect(container.firstElementChild?.tagName).toBe('DIV'); + }); + + it('applies card defaults: subtle bg, xl radius, no shadow, padding 5', () => { + const { container } = render(x); + const el = container.firstElementChild as HTMLElement; + const inline = styleOf(el); + expect(inline).toContain('var(--card-bg)'); + expect(inline).toContain('var(--radius-xl)'); + expect(inline).toContain('var(--shadow-0)'); + expect(inline).toContain('padding: var(--space-5)'); + expect(el).toHaveAttribute('data-variant', 'subtle'); + expect(el).toHaveAttribute('data-bordered', 'true'); + }); + + it('lays children out as a vertical flex with token gap', () => { + const { container } = render(x); + const inline = styleOf(container.firstElementChild!); + expect(inline).toContain('display: flex'); + expect(inline).toContain('flex-direction: column'); + expect(inline).toContain('gap: var(--space-6)'); + }); + + it('uses the default gap of 4 when none is provided', () => { + const { container } = render(x); + expect(styleOf(container.firstElementChild!)).toContain('gap: var(--space-4)'); + }); + + it('can be rendered as
via `as`', () => { + const { container } = render(x); + expect(container.firstElementChild?.tagName).toBe('SECTION'); + }); + + it('forwards refs', () => { + let captured: HTMLElement | null = null; + render( + { + captured = el; + }} + > + x + , + ); + expect(captured).toBeInstanceOf(HTMLElement); + }); + + it('overrides defaults when explicit props are passed', () => { + const { container } = render( + + x + , + ); + const el = container.firstElementChild as HTMLElement; + const inline = styleOf(el); + expect(inline).toContain('var(--bg-elevated)'); + expect(inline).toContain('var(--radius-md)'); + expect(inline).toContain('var(--shadow-3)'); + expect(inline).not.toContain('var(--border)'); + expect(inline).toContain('padding: var(--space-3)'); + }); + + it('marks interactive cards via data-interactive', () => { + const { container } = render(x); + expect(container.firstElementChild).toHaveAttribute('data-interactive', 'true'); + }); +}); + +describe('Card sub-components', () => { + it('renders the full Card.* anatomy with correct semantic tags', () => { + render( + + + My title + My description + + +

row a

+

row b

+
+ + + +
, + ); + + const title = screen.getByRole('heading', { level: 3, name: 'My title' }); + expect(title).toBeInTheDocument(); + + expect(screen.getByText('My description').tagName).toBe('P'); + + const header = screen.getByTestId('hdr'); + expect(header.tagName).toBe('HEADER'); + + const footer = screen.getByTestId('foot'); + expect(footer.tagName).toBe('FOOTER'); + + expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument(); + }); + + it('CardTitle accepts a `level` override', () => { + render(Big); + expect(screen.getByRole('heading', { level: 2, name: 'Big' })).toBeInTheDocument(); + }); + + it('CardDescription accepts size and tone overrides', () => { + const { container } = render( + + small + , + ); + expect(container.firstElementChild).toHaveAttribute('data-tone', 'tertiary'); + }); +}); diff --git a/frontend/src/design-system/primitives/__tests__/Surface.test.tsx b/frontend/src/design-system/primitives/__tests__/Surface.test.tsx new file mode 100644 index 00000000..a27710f9 --- /dev/null +++ b/frontend/src/design-system/primitives/__tests__/Surface.test.tsx @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Surface } from '../Surface'; + +const styleOf = (el: Element) => el.getAttribute('style') ?? ''; + +describe('Surface', () => { + it('renders a
by default', () => { + const { container } = render(x); + expect(container.firstElementChild?.tagName).toBe('DIV'); + }); + + it('respects the polymorphic `as` prop (e.g.
)', () => { + const { container } = render(x); + expect(container.firstElementChild?.tagName).toBe('SECTION'); + }); + + it('forwards refs to the underlying element', () => { + let captured: HTMLElement | null = null; + render( + { + captured = el; + }} + > + ref + , + ); + expect(captured).toBeInstanceOf(HTMLElement); + }); + + it('exposes the variant via data-variant for downstream selectors', () => { + const { container } = render(x); + expect(container.firstElementChild).toHaveAttribute('data-variant', 'raised'); + }); + + it('emits data-bordered="false" when bordered={false}', () => { + const { container } = render(x); + expect(container.firstElementChild).toHaveAttribute('data-bordered', 'false'); + }); + + it('emits token-driven background/border/radius/shadow inline (subtle, xl, elev=2)', () => { + const { container } = render( + + x + , + ); + const inline = styleOf(container.firstElementChild!); + expect(inline).toContain('var(--card-bg)'); + expect(inline).toContain('var(--border)'); + expect(inline).toContain('var(--radius-xl)'); + expect(inline).toContain('var(--shadow-2)'); + }); + + it('emits the elevated background token for variant="raised"', () => { + const { container } = render(x); + expect(styleOf(container.firstElementChild!)).toContain('var(--bg-elevated)'); + }); + + it('omits a background declaration when variant="flat"', () => { + const { container } = render(x); + const inline = styleOf(container.firstElementChild!); + expect(inline).not.toContain('var(--card-bg)'); + expect(inline).not.toContain('var(--bg-elevated)'); + }); + + it('omits the border declaration when bordered={false}', () => { + const { container } = render(x); + expect(styleOf(container.firstElementChild!)).not.toContain('var(--border)'); + }); + + it('marks interactive surfaces via data-interactive', () => { + const { container } = render(x); + expect(container.firstElementChild).toHaveAttribute('data-interactive', 'true'); + }); + + it('translates the spacing tokens to padding (p="5" → var(--space-5))', () => { + const { container } = render(x); + expect(styleOf(container.firstElementChild!)).toContain('padding: var(--space-5)'); + }); + + it('supports per-axis padding (px / py) as longhand', () => { + const { container } = render(x); + const inline = styleOf(container.firstElementChild!); + expect(inline).toContain('padding-left: var(--space-3)'); + expect(inline).toContain('padding-right: var(--space-3)'); + expect(inline).toContain('padding-top: var(--space-6)'); + expect(inline).toContain('padding-bottom: var(--space-6)'); + }); + + it('passes arbitrary HTML attributes through (id, role, aria-*)', () => { + render( + + x + , + ); + const el = screen.getByRole('region', { name: 'my zone' }); + expect(el).toHaveAttribute('id', 'zone'); + }); + + it('renders children verbatim', () => { + render( + + hi + , + ); + expect(screen.getByTestId('child')).toHaveTextContent('hi'); + }); +}); diff --git a/frontend/src/design-system/primitives/index.ts b/frontend/src/design-system/primitives/index.ts index 4693fc4a..11452d1d 100644 --- a/frontend/src/design-system/primitives/index.ts +++ b/frontend/src/design-system/primitives/index.ts @@ -37,4 +37,27 @@ export { type TextAlign, } from './Text'; +/* ─── Surface system (Phase 1d) ──────────────────────────────────────── */ +export { + Surface, + type SurfaceProps, + type SurfaceVariant, + type SurfaceRadius, + type SurfaceElevation, +} from './Surface'; +export { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, + type CardProps, + type CardHeaderProps, + type CardTitleProps, + type CardDescriptionProps, + type CardContentProps, + type CardFooterProps, +} from './Card'; + export { space, cx } from './utils'; diff --git a/frontend/src/pages/DashboardV2/DashboardV2.tsx b/frontend/src/pages/DashboardV2/DashboardV2.tsx index 7b7149d5..9f7731d6 100644 --- a/frontend/src/pages/DashboardV2/DashboardV2.tsx +++ b/frontend/src/pages/DashboardV2/DashboardV2.tsx @@ -16,6 +16,7 @@ import OperationsTimeline from '../Dashboard/components/OperationsTimeline'; import AlertsStrip from './components/AlertsStrip'; import WarehouseSnapshot from './components/WarehouseSnapshot'; import UpcomingPanel from './components/UpcomingPanel'; +import { Card } from '../../design-system'; import s from './DashboardV2.module.css'; export type DashboardPeriod = 'day' | 'week' | 'month' | 'season'; @@ -288,9 +289,15 @@ export default function DashboardV2({

{dash.warehouseSnapshot}

-
+ {/* + Phase 1d adoption: replaces the local `s.card` wrapper with the + design-system `` primitive. Visual contract preserved + (subtle bg, xl radius, 20px padding, hairline border) but now + sourced from tokens rather than magic numbers. + */} + -
+

{dash.upcoming}