feat(primitives): Button white + Card glow + StatCard + KbdHint + PageHeader + AppShellLayout — 7C.4

This commit is contained in:
reloop
2026-04-12 03:20:21 +09:00
parent 140a7c38f0
commit b9874ad25a

View File

@@ -1,16 +1,21 @@
'use client';
import React from 'react';
import styled, { css } from 'styled-components';
import { theme } from '@/styles/theme';
export const Card = styled.div<{ $hoverable?: boolean }>`
export const Card = styled.div<{
$hoverable?: boolean;
$variant?: 'default' | 'hoverable' | 'glow';
}>`
background: ${theme.color.surface};
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.lg};
padding: ${theme.space.lg};
position: relative;
${({ $hoverable }) =>
$hoverable &&
${({ $hoverable, $variant }) =>
($hoverable || $variant === 'hoverable') &&
css`
transition: border-color 0.15s ease, transform 0.15s ease;
cursor: pointer;
@@ -19,10 +24,37 @@ export const Card = styled.div<{ $hoverable?: boolean }>`
transform: translateY(-1px);
}
`}
${({ $variant }) =>
$variant === 'glow' &&
css`
overflow: hidden;
&::before {
content: '';
position: absolute;
top: -40px;
left: 50%;
transform: translateX(-50%);
width: 160px;
height: 160px;
background: radial-gradient(
circle,
rgba(99, 102, 241, 0.18) 0%,
transparent 70%
);
filter: blur(80px);
pointer-events: none;
z-index: 0;
}
& > * {
position: relative;
z-index: 1;
}
`}
`;
export const Button = styled.button<{
$variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
$variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'white';
$block?: boolean;
$size?: 'sm' | 'md' | 'lg';
}>`
@@ -102,6 +134,16 @@ export const Button = styled.button<{
filter: brightness(1.1);
}
`;
case 'white':
return css`
background: #ffffff;
color: ${theme.color.bg};
border: 1px solid rgba(0, 0, 0, 0.08);
&:hover:not(:disabled) {
background: ${theme.color.textBright};
box-shadow: ${theme.shadow.md};
}
`;
}
}}
@@ -273,3 +315,291 @@ export const Selectable = styled.span.attrs<{ 'data-selectable'?: string }>({
user-select: text;
-webkit-touch-callout: default;
`;
// ─── Phase 7 추가 컴포넌트 ──────────────────────────────────────────────────
// StatCard ─────────────────────────────────────────────────────────────────
export interface StatCardDelta {
value: string;
direction: 'up' | 'down';
hint?: string;
}
export interface StatCardProps {
label: string;
value: string | number;
unit?: string;
delta?: StatCardDelta;
icon?: React.ReactNode;
children?: React.ReactNode;
}
const StatCardRoot = styled.div`
background: ${theme.color.surface};
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.lg};
padding: ${theme.space.lg};
display: flex;
flex-direction: column;
gap: ${theme.space.sm};
`;
const StatCardHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const StatCardLabel = styled.span`
font-size: 12px;
font-weight: 600;
color: ${theme.color.textSub};
text-transform: uppercase;
letter-spacing: 0.05em;
`;
const StatCardIconSlot = styled.span`
color: ${theme.color.textMute};
display: flex;
align-items: center;
`;
const StatCardValueRow = styled.div`
display: flex;
align-items: baseline;
gap: ${theme.space.xs};
`;
const StatCardValue = styled.span`
font-family: ${theme.font.display};
font-size: 48px;
font-weight: 700;
line-height: 1;
color: ${theme.color.textMain};
font-variant-numeric: tabular-nums;
`;
const StatCardUnit = styled.span`
font-size: 18px;
font-weight: 500;
color: ${theme.color.textSub};
`;
const StatCardDeltaBadge = styled.span<{ $direction: 'up' | 'down' }>`
display: inline-flex;
align-items: center;
gap: 3px;
padding: 3px 8px;
border-radius: ${theme.radius.pill};
font-size: 12px;
font-weight: 700;
${({ $direction }) =>
$direction === 'up'
? css`
background: rgba(34, 197, 94, 0.15);
color: ${theme.color.success};
border: 1px solid rgba(34, 197, 94, 0.3);
`
: css`
background: rgba(239, 68, 68, 0.15);
color: ${theme.color.danger};
border: 1px solid rgba(239, 68, 68, 0.3);
`}
`;
const StatCardChildren = styled.div`
margin-top: ${theme.space.xs};
`;
export function StatCard({
label,
value,
unit,
delta,
icon,
children,
}: StatCardProps) {
return (
<StatCardRoot>
<StatCardHeader>
<StatCardLabel>{label}</StatCardLabel>
{icon && <StatCardIconSlot>{icon}</StatCardIconSlot>}
</StatCardHeader>
<StatCardValueRow>
<StatCardValue>{value}</StatCardValue>
{unit && <StatCardUnit>{unit}</StatCardUnit>}
{delta && (
<StatCardDeltaBadge $direction={delta.direction} title={delta.hint}>
{delta.direction === 'up' ? '▲' : '▼'} {delta.value}
</StatCardDeltaBadge>
)}
</StatCardValueRow>
{children && <StatCardChildren>{children}</StatCardChildren>}
</StatCardRoot>
);
}
// KbdHint ──────────────────────────────────────────────────────────────────
export interface KbdHintProps {
keys: string[];
label: string;
}
const KbdHintRoot = styled.span`
display: inline-flex;
align-items: center;
gap: ${theme.space.xs};
font-size: 13px;
color: ${theme.color.textSub};
`;
const KbdKey = styled.kbd`
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
padding: 2px 6px;
background: ${theme.color.surface2};
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.sm};
font-family: ${theme.font.mono};
font-size: 11px;
font-weight: 600;
color: ${theme.color.textMain};
line-height: 1.6;
box-shadow: 0 1px 0 ${theme.color.border};
`;
const KbdPlusSep = styled.span`
font-size: 11px;
color: ${theme.color.textMute};
`;
export function KbdHint({ keys, label }: KbdHintProps) {
return (
<KbdHintRoot>
{keys.map((k, i) => (
<React.Fragment key={k}>
{i > 0 && <KbdPlusSep>+</KbdPlusSep>}
<KbdKey>{k}</KbdKey>
</React.Fragment>
))}
<span>{label}</span>
</KbdHintRoot>
);
}
// PageHeader ───────────────────────────────────────────────────────────────
export interface PageHeaderProps {
title: string;
subtitle?: string;
eyebrow?: string;
right?: React.ReactNode;
children?: React.ReactNode;
}
const PageHeaderRoot = styled.div`
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: ${theme.space.md};
padding-bottom: ${theme.space.lg};
`;
const PageHeaderLeft = styled.div`
display: flex;
flex-direction: column;
gap: ${theme.space.xs};
`;
const PageHeaderEyebrow = styled.span`
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: ${theme.color.accent};
`;
const PageHeaderTitle = styled.h1`
font-size: 28px;
font-weight: 700;
color: ${theme.color.textMain};
line-height: 1.2;
margin: 0;
`;
const PageHeaderSubtitle = styled.p`
font-size: 14px;
color: ${theme.color.textSub};
margin: 0;
`;
const PageHeaderRight = styled.div`
display: flex;
align-items: center;
gap: ${theme.space.sm};
flex-shrink: 0;
`;
export function PageHeader({
title,
subtitle,
eyebrow,
right,
children,
}: PageHeaderProps) {
return (
<PageHeaderRoot>
<PageHeaderLeft>
{eyebrow && <PageHeaderEyebrow>{eyebrow}</PageHeaderEyebrow>}
<PageHeaderTitle>{title}</PageHeaderTitle>
{subtitle && <PageHeaderSubtitle>{subtitle}</PageHeaderSubtitle>}
{children}
</PageHeaderLeft>
{right && <PageHeaderRight>{right}</PageHeaderRight>}
</PageHeaderRoot>
);
}
// AppShellLayout ───────────────────────────────────────────────────────────
export interface AppShellLayoutProps {
sidebar: React.ReactNode;
children: React.ReactNode;
}
const AppShellRoot = styled.div`
display: flex;
height: 100vh;
width: 100%;
overflow: hidden;
background: ${theme.color.bg};
`;
const AppShellSidebar = styled.aside`
width: 240px;
flex-shrink: 0;
height: 100%;
overflow-y: auto;
border-right: 1px solid ${theme.color.border};
background: ${theme.color.surface};
`;
const AppShellMain = styled.main`
flex: 1;
height: 100%;
overflow-y: auto;
min-width: 0;
`;
export function AppShellLayout({ sidebar, children }: AppShellLayoutProps) {
return (
<AppShellRoot>
<AppShellSidebar>{sidebar}</AppShellSidebar>
<AppShellMain>{children}</AppShellMain>
</AppShellRoot>
);
}