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
9 changes: 9 additions & 0 deletions frontend/src/design-system/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Public entry-point for the design system.
*
* Import from `@/design-system` (or relative path) — never reach into
* sub-folders directly. This keeps the public surface stable.
*/

export * from './tokens';
export * from './primitives';
88 changes: 88 additions & 0 deletions frontend/src/design-system/primitives/Box.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { forwardRef, type CSSProperties, type ElementType, type HTMLAttributes, type Ref } from 'react';
import type { SpacingKey } from '../tokens';
import { space } from './utils';

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

export interface BoxProps extends HTMLAttributes<HTMLElement> {
as?: BoxAs;
/** Shorthand for 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;
/** Margin shorthand. */
m?: SpacingKey;
mx?: SpacingKey;
my?: SpacingKey;
mt?: SpacingKey;
mr?: SpacingKey;
mb?: SpacingKey;
ml?: SpacingKey;
/** Block-level width control (CSS value pass-through, e.g. '100%', '320px'). */
width?: CSSProperties['width'];
height?: CSSProperties['height'];
/** Convenience flag to render with `display: flex` (rare; prefer Stack/Cluster). */
flex?: boolean;
}

/**
* Box — the most primitive layout building block.
*
* Use it when you need spacing/sizing without flex semantics.
* Prefer `Stack`/`Cluster` for one-dimensional layouts and `Container`
* for max-width centred regions.
*/
export const Box = forwardRef<HTMLElement, BoxProps>(function Box(
{
as = 'div',
p, px, py, pt, pr, pb, pl,
m, mx, my, mt, mr, mb, ml,
width, height, flex,
style, children, ...rest
},
ref,
) {
const Component = as as ElementType;
const inline: CSSProperties = { ...style };

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);

if (m !== undefined) inline.margin = space(m);
if (mx !== undefined) { inline.marginLeft = space(mx); inline.marginRight = space(mx); }
if (my !== undefined) { inline.marginTop = space(my); inline.marginBottom = space(my); }
if (mt !== undefined) inline.marginTop = space(mt);
if (mr !== undefined) inline.marginRight = space(mr);
if (mb !== undefined) inline.marginBottom = space(mb);
if (ml !== undefined) inline.marginLeft = space(ml);

if (width !== undefined) inline.width = width;
if (height !== undefined) inline.height = height;
if (flex) inline.display = 'flex';

return (
<Component ref={ref as Ref<HTMLElement>} style={inline} {...rest}>
{children}
</Component>
);
});
21 changes: 21 additions & 0 deletions frontend/src/design-system/primitives/Cluster.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.cluster {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--ds-cluster-gap, 0);
min-width: 0;
}

.cluster[data-nowrap='true'] { flex-wrap: nowrap; }

.cluster[data-align='start'] { align-items: flex-start; }
.cluster[data-align='center'] { align-items: center; }
.cluster[data-align='end'] { align-items: flex-end; }
.cluster[data-align='baseline'] { align-items: baseline; }
.cluster[data-align='stretch'] { align-items: stretch; }

.cluster[data-justify='start'] { justify-content: flex-start; }
.cluster[data-justify='center'] { justify-content: center; }
.cluster[data-justify='end'] { justify-content: flex-end; }
.cluster[data-justify='between'] { justify-content: space-between; }
.cluster[data-justify='around'] { justify-content: space-around; }
54 changes: 54 additions & 0 deletions frontend/src/design-system/primitives/Cluster.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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 './Cluster.module.css';

type ClusterAs =
| 'div'
| 'span'
| 'header'
| 'footer'
| 'nav'
| 'ul'
| 'ol';

export type ClusterAlign = 'start' | 'center' | 'end' | 'baseline' | 'stretch';
export type ClusterJustify = 'start' | 'center' | 'end' | 'between' | 'around';

export interface ClusterProps extends HTMLAttributes<HTMLElement> {
as?: ClusterAs;
/** Horizontal gap between children. Defaults to `'2'` (8 px). */
gap?: SpacingKey;
align?: ClusterAlign;
justify?: ClusterJustify;
/** When true, items never wrap to a new row. Default: false (wrap). */
nowrap?: boolean;
}

/**
* Cluster — horizontal one-dimensional layout that wraps by default.
*
* Use it for tag rows, button groups, breadcrumbs, toolbar items —
* any horizontal collection that should reflow gracefully on small screens.
*/
export const Cluster = forwardRef<HTMLElement, ClusterProps>(function Cluster(
{ as = 'div', gap = '2', align = 'center', justify, nowrap, className, style, children, ...rest },
ref,
) {
const Component = as as ElementType;
const inline: CSSProperties = { ...style, ['--ds-cluster-gap' as string]: space(gap) };

return (
<Component
ref={ref as Ref<HTMLElement>}
className={cx(s.cluster, className)}
data-align={align}
data-justify={justify}
data-nowrap={nowrap ? 'true' : undefined}
style={inline}
{...rest}
>
{children}
</Component>
);
});
14 changes: 14 additions & 0 deletions frontend/src/design-system/primitives/Container.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.container {
width: 100%;
margin-inline: auto;
padding-inline: var(--ds-container-px, var(--space-4));
max-width: var(--ds-container-max, none);
}

@media (min-width: 600px) {
.container { padding-inline: var(--ds-container-px-md, var(--space-6)); }
}

@media (min-width: 1024px) {
.container { padding-inline: var(--ds-container-px-lg, var(--space-8)); }
}
48 changes: 48 additions & 0 deletions frontend/src/design-system/primitives/Container.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { forwardRef, type CSSProperties, type ElementType, type HTMLAttributes, type Ref } from 'react';
import { cx } from './utils';
import s from './Container.module.css';

type ContainerAs = 'div' | 'section' | 'article' | 'main' | 'header' | 'footer';

/** Max-content widths for each preset (px). Aligned with breakpoints. */
const SIZE_MAP = {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1440px',
full: 'none',
} as const;

export type ContainerSize = keyof typeof SIZE_MAP;

export interface ContainerProps extends HTMLAttributes<HTMLElement> {
as?: ContainerAs;
/** Max-width preset. Defaults to `'xl'` (1280 px). */
size?: ContainerSize;
}

/**
* Container — horizontally-centred, max-width-clamped page region.
*
* Provides responsive horizontal padding (`--space-4 → --space-6 → --space-8`)
* so content never touches the viewport edge.
*/
export const Container = forwardRef<HTMLElement, ContainerProps>(function Container(
{ as = 'div', size = 'xl', className, style, children, ...rest },
ref,
) {
const Component = as as ElementType;
const inline: CSSProperties = { ...style, ['--ds-container-max' as string]: SIZE_MAP[size] };

return (
<Component
ref={ref as Ref<HTMLElement>}
className={cx(s.container, className)}
style={inline}
{...rest}
>
{children}
</Component>
);
});
23 changes: 23 additions & 0 deletions frontend/src/design-system/primitives/Heading.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.heading {
margin: 0;
font-family: var(--font-sans);
color: var(--text-primary);
letter-spacing: var(--ds-h-tracking, normal);
font-size: var(--ds-h-size);
line-height: var(--ds-h-lh);
font-weight: var(--ds-h-weight);
}

.heading[data-truncate='true'] {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}

.heading[data-align='left'] { text-align: left; }
.heading[data-align='center'] { text-align: center; }
.heading[data-align='right'] { text-align: right; }

.heading[data-tone='muted'] { color: var(--text-secondary); }
.heading[data-tone='subtle'] { color: var(--text-tertiary); }
106 changes: 106 additions & 0 deletions frontend/src/design-system/primitives/Heading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { forwardRef, type CSSProperties, type ElementType, type HTMLAttributes, type Ref } from 'react';
import { cx } from './utils';
import s from './Heading.module.css';

export type HeadingLevel = 1 | 2 | 3 | 4 | 'display';
export type HeadingWeight = 'normal' | 'medium' | 'semibold' | 'bold';
export type HeadingTone = 'default' | 'muted' | 'subtle';
export type HeadingAlign = 'left' | 'center' | 'right';

type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';

const SCALE: Record<
HeadingLevel,
{ size: string; lh: string; weight: string; tracking: string; tag: HeadingTag }
> = {
display: {
size: 'var(--font-size-display)',
lh: 'var(--line-height-display)',
weight: 'var(--font-weight-display-step, var(--font-weight-bold))',
tracking: 'var(--letter-spacing-display, -0.02em)',
tag: 'h1',
},
1: {
size: 'var(--font-size-h1)',
lh: 'var(--line-height-h1)',
weight: 'var(--font-weight-h1-step, var(--font-weight-bold))',
tracking: 'var(--letter-spacing-h1, -0.01em)',
tag: 'h1',
},
2: {
size: 'var(--font-size-h2)',
lh: 'var(--line-height-h2)',
weight: 'var(--font-weight-h2-step, var(--font-weight-semibold))',
tracking: 'normal',
tag: 'h2',
},
3: {
size: 'var(--font-size-h3)',
lh: 'var(--line-height-h3)',
weight: 'var(--font-weight-h3-step, var(--font-weight-semibold))',
tracking: 'normal',
tag: 'h3',
},
4: {
size: 'var(--font-size-h4)',
lh: 'var(--line-height-h4)',
weight: 'var(--font-weight-h4-step, var(--font-weight-semibold))',
tracking: 'normal',
tag: 'h4',
},
};

const WEIGHT_VAR: Record<HeadingWeight, string> = {
normal: 'var(--font-weight-normal)',
medium: 'var(--font-weight-medium)',
semibold: 'var(--font-weight-semibold)',
bold: 'var(--font-weight-bold)',
};

export interface HeadingProps extends HTMLAttributes<HTMLHeadingElement> {
/** Visual level. Default `2`. */
level?: HeadingLevel;
/** Override the rendered HTML tag (defaults to the semantic match for `level`). */
as?: HeadingTag;
/** Override the level's default weight. */
weight?: HeadingWeight;
align?: HeadingAlign;
tone?: HeadingTone;
/** Single-line ellipsis truncation. */
truncate?: boolean;
}

/**
* Heading — semantic, fluid-scaled section title.
*
* Visual size and HTML tag are decoupled: pick `level` for visuals,
* use `as` only when document outline requires a different tag (rare).
*/
export const Heading = forwardRef<HTMLHeadingElement, HeadingProps>(function Heading(
{ level = 2, as, weight, align, tone = 'default', truncate, className, style, children, ...rest },
ref,
) {
const scale = SCALE[level];
const Component = (as ?? scale.tag) as ElementType;
const inline: CSSProperties = {
...style,
['--ds-h-size' as string]: scale.size,
['--ds-h-lh' as string]: scale.lh,
['--ds-h-weight' as string]: weight ? WEIGHT_VAR[weight] : scale.weight,
['--ds-h-tracking' as string]: scale.tracking,
};

return (
<Component
ref={ref as Ref<HTMLHeadingElement>}
className={cx(s.heading, className)}
data-align={align}
data-tone={tone === 'default' ? undefined : tone}
data-truncate={truncate ? 'true' : undefined}
style={inline}
{...rest}
>
{children}
</Component>
);
});
22 changes: 22 additions & 0 deletions frontend/src/design-system/primitives/Stack.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.stack {
display: flex;
flex-direction: column;
gap: var(--ds-stack-gap, 0);
min-width: 0;
}

.stack[data-align='start'] { align-items: flex-start; }
.stack[data-align='center'] { align-items: center; }
.stack[data-align='end'] { align-items: flex-end; }
.stack[data-align='stretch'] { align-items: stretch; }

.stack[data-justify='start'] { justify-content: flex-start; }
.stack[data-justify='center'] { justify-content: center; }
.stack[data-justify='end'] { justify-content: flex-end; }
.stack[data-justify='between'] { justify-content: space-between; }
.stack[data-justify='around'] { justify-content: space-around; }

.stack[data-divide='true'] > * + * {
border-top: 1px solid var(--border);
padding-top: var(--ds-stack-gap, 0);
}
Loading
Loading