전 저장소 리팩터링과 SSOT 정비
This commit is contained in:
parent
14ecbd4e7d
commit
3dfddcac6f
173 changed files with 19679 additions and 6952 deletions
31
apps/web/src/components/auth/AuthShell.tsx
Normal file
31
apps/web/src/components/auth/AuthShell.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type AuthShellVariant = "botanical" | "room";
|
||||
|
||||
export interface AuthShellProps extends HTMLAttributes<HTMLElement> {
|
||||
variant?: AuthShellVariant;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** 로그인·온보딩·승인 대기가 공유하는 viewport 캔버스. */
|
||||
export function AuthShell({
|
||||
variant = "botanical",
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: AuthShellProps) {
|
||||
return (
|
||||
<main
|
||||
className={[
|
||||
"vg-auth-shell",
|
||||
`vg-auth-shell--${variant}`,
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
85
apps/web/src/components/auth/auth-shell.css
Normal file
85
apps/web/src/components/auth/auth-shell.css
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
.vg-auth-shell {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
inline-size: 100vw;
|
||||
max-inline-size: none;
|
||||
min-block-size: 100vh;
|
||||
min-block-size: 100dvh;
|
||||
overflow-x: clip;
|
||||
color: var(--text-body);
|
||||
background-color: var(--bg-app);
|
||||
}
|
||||
|
||||
.vg-auth-shell > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.vg-auth-shell.vg-auth-shell--botanical {
|
||||
background:
|
||||
var(--asset-botanical-corner) right top / min(64vw, 860px) auto no-repeat,
|
||||
radial-gradient(circle at 72% 10%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 32rem),
|
||||
radial-gradient(circle at 28% 78%, color-mix(in srgb, var(--botanical-gold) 14%, transparent), transparent 40rem),
|
||||
var(--glass-canvas);
|
||||
}
|
||||
|
||||
.vg-auth-shell.vg-auth-shell--botanical::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
left: -8vw;
|
||||
bottom: -90px;
|
||||
width: min(66vw, 860px);
|
||||
height: min(74vw, 980px);
|
||||
background: var(--asset-botanical-corner) left bottom / contain no-repeat;
|
||||
opacity: 0.55;
|
||||
transform: rotate(180deg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vg-auth-shell.vg-auth-shell--room {
|
||||
--auth-room-veil:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(247, 244, 236, 0.94) 0%,
|
||||
rgba(243, 238, 227, 0.86) 48%,
|
||||
rgba(238, 231, 217, 0.72) 74%,
|
||||
rgba(232, 223, 207, 0.62) 100%
|
||||
);
|
||||
--auth-room-shade: linear-gradient(180deg, rgba(255, 253, 248, 0.08), rgba(120, 101, 72, 0.12));
|
||||
--auth-hero-text: var(--text-strong);
|
||||
--auth-hero-muted: color-mix(in srgb, var(--text-body) 88%, transparent);
|
||||
--auth-hero-surface: rgba(255, 254, 251, 0.48);
|
||||
--auth-hero-border: rgba(101, 86, 62, 0.22);
|
||||
--auth-hero-chip: rgba(255, 254, 251, 0.62);
|
||||
background:
|
||||
var(--auth-room-veil),
|
||||
var(--asset-login-room) center / cover no-repeat;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .vg-auth-shell.vg-auth-shell--room {
|
||||
--auth-room-veil:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(7, 16, 14, 0.84) 0%,
|
||||
rgba(10, 21, 19, 0.72) 48%,
|
||||
rgba(10, 21, 19, 0.55) 74%,
|
||||
rgba(10, 21, 19, 0.62) 100%
|
||||
);
|
||||
--auth-room-shade: linear-gradient(180deg, rgba(3, 9, 8, 0.18), rgba(3, 9, 8, 0.42));
|
||||
--auth-hero-text: #edf4f2;
|
||||
--auth-hero-muted: rgba(237, 244, 242, 0.72);
|
||||
--auth-hero-surface: rgba(237, 244, 242, 0.08);
|
||||
--auth-hero-border: rgba(255, 255, 255, 0.14);
|
||||
--auth-hero-chip: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.vg-auth-shell.vg-auth-shell--botanical::after {
|
||||
left: -30vw;
|
||||
bottom: -24px;
|
||||
width: 110vw;
|
||||
height: 120vw;
|
||||
opacity: 0.42;
|
||||
}
|
||||
}
|
||||
|
|
@ -35,7 +35,6 @@ import { Brows } from "./Brows";
|
|||
import { Mouth } from "./Mouth";
|
||||
import { live2dModel3Path, live2dModelForPersonaCode, live2dMotionForExpression } from "./live2dModel";
|
||||
import { useExpressionTransition } from "./useExpressionTransition";
|
||||
import { RasterBust } from "./RasterBust";
|
||||
import "./client-avatar.css";
|
||||
|
||||
/* ── 공개 타입 재노출 (기존 import 경로 호환) ──────────────────────────
|
||||
|
|
@ -232,7 +231,6 @@ export function ClientAvatar({
|
|||
const outfitColor = persona.outfitColor ?? hairColor;
|
||||
const irisColor = persona.eyeColor ?? "#2B2B2B";
|
||||
const realism = Math.min(0.45, Math.max(0.3, persona.realism)); // 안전 클램프
|
||||
const useRaster = Boolean(persona.rasterArtSet);
|
||||
const age = ageLookFor(persona.ageBand);
|
||||
const expressionLabel = expressionLabelFor(affect);
|
||||
const live2dModel = useMemo(() => live2dModelForPersonaCode(persona.code), [persona.code]);
|
||||
|
|
@ -295,7 +293,7 @@ export function ClientAvatar({
|
|||
data-live2d-expression-count={live2dModel.expressions.length}
|
||||
data-persona-code={persona.code ?? ""}
|
||||
data-avatar-animated={motionEnabled ? "true" : "false"}
|
||||
data-render-mode={useRaster ? "raster" : "svg"}
|
||||
data-render-mode="svg"
|
||||
data-realism={realism} /* 사실성은 데이터로만 유지(시각 표식 비노출, §4.6) */
|
||||
aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}, ${expressionLabel}`}
|
||||
>
|
||||
|
|
@ -319,28 +317,14 @@ export function ClientAvatar({
|
|||
reduced={reduced}
|
||||
/>
|
||||
|
||||
{useRaster ? (
|
||||
<RasterBust
|
||||
artSet={persona.rasterArtSet as string}
|
||||
affect={affect}
|
||||
state={state}
|
||||
breath={breath}
|
||||
blink={blink}
|
||||
mouth={mouthOpen}
|
||||
gazeX={gazeX}
|
||||
gazeY={gazeY}
|
||||
headTilt={params.headTilt}
|
||||
reduced={reduced}
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
className="vg-avatar__svg"
|
||||
viewBox="0 0 200 200"
|
||||
width={size}
|
||||
height={size}
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
className="vg-avatar__svg"
|
||||
viewBox="0 0 200 200"
|
||||
width={size}
|
||||
height={size}
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* 흉상 그룹: 어깨 호흡(translateY) + 저항 시 미세 회전 */}
|
||||
<g transform={`translate(0 ${-breath}) rotate(${shoulderRotate} 100 150)`}>
|
||||
{/* 어깨/상반신 실루엣 */}
|
||||
|
|
@ -381,8 +365,7 @@ export function ClientAvatar({
|
|||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,18 @@ const PERSONA_GENERATED_ART_SETS = [
|
|||
];
|
||||
const PSB_GROUP_ART_SETS = new Set(["seoyeon-live2d-psb", "seoyeon-live2d-psd-v2", ...PERSONA_GENERATED_ART_SETS]);
|
||||
const PSB_CRYING_ART_SETS = new Set(["seoyeon-live2d-psd-v2", ...PERSONA_GENERATED_ART_SETS]);
|
||||
const EYE_DETAIL_LAYERS: ReadonlyArray<{
|
||||
name: string;
|
||||
mask: string;
|
||||
zIndex: number;
|
||||
}> = [
|
||||
{ name: "iris-left", mask: "eye-white-left", zIndex: 9 },
|
||||
{ name: "iris-right", mask: "eye-white-right", zIndex: 9 },
|
||||
{ name: "pupil-left", mask: "eye-white-left", zIndex: 10 },
|
||||
{ name: "pupil-right", mask: "eye-white-right", zIndex: 10 },
|
||||
{ name: "highlight-left", mask: "eye-white-left", zIndex: 11 },
|
||||
{ name: "highlight-right", mask: "eye-white-right", zIndex: 11 },
|
||||
];
|
||||
|
||||
export function rasterVariantFor(expression: AvatarExpression): RasterVariant {
|
||||
return VARIANT_FOR_EXPRESSION[expression];
|
||||
|
|
@ -294,14 +306,6 @@ export function RasterBust({
|
|||
const browLeftPart = useSadCryingParts ? "brow-sad-left" : "brow-left";
|
||||
const browRightPart = useSadCryingParts ? "brow-sad-right" : "brow-right";
|
||||
const tearOpacity = useSadCryingParts ? clamp01(0.82 + openEyes * 0.18 - closedEyes * 0.35) : 0;
|
||||
const eyeDetailLayers: Array<{ name: string; mask: string; zIndex: number }> = [
|
||||
{ name: "iris-left", mask: "eye-white-left", zIndex: 9 },
|
||||
{ name: "iris-right", mask: "eye-white-right", zIndex: 9 },
|
||||
{ name: "pupil-left", mask: "eye-white-left", zIndex: 10 },
|
||||
{ name: "pupil-right", mask: "eye-white-right", zIndex: 10 },
|
||||
{ name: "highlight-left", mask: "eye-white-left", zIndex: 11 },
|
||||
{ name: "highlight-right", mask: "eye-white-right", zIndex: 11 },
|
||||
];
|
||||
const renderLayer = (
|
||||
name: string,
|
||||
className: string,
|
||||
|
|
@ -395,7 +399,7 @@ export function RasterBust({
|
|||
...eyeApertureClipStyle(name.endsWith("left") ? "left" : "right", upperLidY, lowerLidY, closedEyes),
|
||||
}),
|
||||
)}
|
||||
{eyeDetailLayers.map(({ name, mask, zIndex }) =>
|
||||
{EYE_DETAIL_LAYERS.map(({ name, mask, zIndex }) =>
|
||||
renderLayer(name, "vg-raster__layer--eyes", zIndex, {
|
||||
opacity: openEyes,
|
||||
transform: layerTransform(
|
||||
|
|
@ -526,14 +530,6 @@ export function RasterBust({
|
|||
const hairSway = reduced ? 0 : breath * 0.24 + headTilt * 0.18;
|
||||
const brow = browPoseForVariant(variant);
|
||||
const expressionMouth = mouthForVariant(variant);
|
||||
const eyeDetailLayers: Array<{ name: string; mask: string; zIndex: number }> = [
|
||||
{ name: "iris-left", mask: "eye-white-left", zIndex: 9 },
|
||||
{ name: "iris-right", mask: "eye-white-right", zIndex: 9 },
|
||||
{ name: "pupil-left", mask: "eye-white-left", zIndex: 10 },
|
||||
{ name: "pupil-right", mask: "eye-white-right", zIndex: 10 },
|
||||
{ name: "highlight-left", mask: "eye-white-left", zIndex: 11 },
|
||||
{ name: "highlight-right", mask: "eye-white-right", zIndex: 11 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -626,7 +622,7 @@ export function RasterBust({
|
|||
style={{ opacity: openEyes, zIndex: name.startsWith("lash") ? 12 : 9, transform: layerTransform() }}
|
||||
/>
|
||||
))}
|
||||
{eyeDetailLayers.map(
|
||||
{EYE_DETAIL_LAYERS.map(
|
||||
({ name, mask, zIndex }) => (
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--eyes"
|
||||
|
|
|
|||
|
|
@ -118,12 +118,6 @@ export interface AvatarPersona {
|
|||
eyeColor?: string;
|
||||
/** 캐릭터 고유 보조색 */
|
||||
accentColor?: string;
|
||||
/**
|
||||
* 래스터(비트맵) 아트셋 코드. 지정하면 SVG 파라미터 리그 대신
|
||||
* `/avatar/<artSet>/<variant>.png` 레이어 합성(Live2D식)으로 렌더링한다.
|
||||
* 미지정 시 기존 SVG 리그. asset 은 imagegen(gpt-image-2)+BiRefNet 누끼로 생성.
|
||||
*/
|
||||
rasterArtSet?: string;
|
||||
/** 사실성 0.35~0.45 고정 (불쾌한 골짜기 회피) */
|
||||
realism: number;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { designRoleOf, useAuth, type Role } from "../../lib/auth";
|
|||
|
||||
export interface AppShellProps {
|
||||
children: ReactNode;
|
||||
/** 페이지 전용 셸 표면을 위한 추가 클래스 */
|
||||
className?: string;
|
||||
/** 역할 컨텍스트 라벨 오버라이드 (없으면 user.role 라벨) */
|
||||
contextLabel?: string;
|
||||
/** 좌측 네비 숨김 (세션 화면처럼 집중 모드) */
|
||||
|
|
@ -24,7 +26,7 @@ export interface AppShellProps {
|
|||
* body[data-role] 은 AuthProvider 가 관리(여기선 셸 골격만).
|
||||
* 미인증 시에도 안전하게 렌더(네비 없이) — 가드는 라우터(RequireAuth)가 담당.
|
||||
*/
|
||||
export function AppShell({ children, contextLabel, hideNav, bleed, wide, hideTopbar, navRole }: AppShellProps) {
|
||||
export function AppShell({ children, className, contextLabel, hideNav, bleed, wide, hideTopbar, navRole }: AppShellProps) {
|
||||
const { user } = useAuth();
|
||||
const showNav = !hideNav && !!user;
|
||||
const shellRole = navRole ?? user?.role;
|
||||
|
|
@ -46,7 +48,16 @@ export function AppShell({ children, contextLabel, hideNav, bleed, wide, hideTop
|
|||
}, [shellRole, user]);
|
||||
|
||||
return (
|
||||
<div className={"vg-shell" + (hideTopbar ? " vg-shell--fullscreen" : "")}>
|
||||
<div
|
||||
className={[
|
||||
"vg-shell",
|
||||
"vg-shell--botanical",
|
||||
hideTopbar ? "vg-shell--fullscreen" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{hideTopbar ? null : <Topbar contextLabel={contextLabel} />}
|
||||
<div className={"vg-shell__body" + (showNav ? "" : " vg-shell__body--bare")}>
|
||||
{showNav ? <Sidebar role={shellRole ?? user.role} /> : null}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const NAV_BY_ROLE: Record<Role, NavItem[]> = {
|
|||
],
|
||||
admin: [
|
||||
{ to: "/admin", label: "운영 홈", icon: "shield", end: true },
|
||||
{ to: "/admin/ai", label: "AI 운영", icon: "session" },
|
||||
{ to: "/admin/users", label: "사용자", icon: "users" },
|
||||
{ to: "/admin/access", label: "권한", icon: "settings" },
|
||||
{ to: "/admin/tickets", label: "티켓", icon: "review" },
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { accessibleRolesFor, canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth";
|
||||
import { applyTheme, readInitialTheme, type AppTheme } from "../../lib/theme";
|
||||
import { useTheme } from "../../lib/useTheme";
|
||||
|
||||
/** Vignette 워드마크 — 비네트(조리개) inline SVG. dev_dashboard 마크 계승. */
|
||||
function BrandMark() {
|
||||
|
|
@ -20,15 +19,6 @@ function BrandMark() {
|
|||
);
|
||||
}
|
||||
|
||||
/** 라이트/다크 토글 (data-theme 반영). 토큰만으로 전환. */
|
||||
function useTheme(): [boolean, () => void] {
|
||||
const [theme, setTheme] = useState<AppTheme>(() => readInitialTheme());
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
return [theme === "dark", () => setTheme((current) => (current === "dark" ? "light" : "dark"))];
|
||||
}
|
||||
|
||||
function initials(name?: string | null): string {
|
||||
const trimmed = (name ?? "").trim();
|
||||
if (!trimmed) return "·";
|
||||
|
|
@ -46,7 +36,7 @@ export function Topbar({ contextLabel }: TopbarProps) {
|
|||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [dark, toggleTheme] = useTheme();
|
||||
const [dark, setDark] = useTheme();
|
||||
|
||||
const label = contextLabel ?? (user ? roleLabel(user.role) : null);
|
||||
const switchRoles: Role[] = user ? accessibleRolesFor(user) : [];
|
||||
|
|
@ -100,7 +90,7 @@ export function Topbar({ contextLabel }: TopbarProps) {
|
|||
<button
|
||||
type="button"
|
||||
className="vg-iconbtn"
|
||||
onClick={toggleTheme}
|
||||
onClick={() => setDark(!dark)}
|
||||
aria-label={dark ? "라이트 모드로" : "다크 모드로"}
|
||||
title={dark ? "라이트 모드" : "다크 모드"}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -5,15 +5,22 @@
|
|||
===================================================================== */
|
||||
|
||||
.vg-shell {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg-app);
|
||||
}
|
||||
.vg-shell--fullscreen {
|
||||
min-height: 100dvh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
/* ── 톱바 ── */
|
||||
.vg-topbar {
|
||||
flex: 0 0 var(--topbar-h);
|
||||
min-width: 0;
|
||||
height: var(--topbar-h);
|
||||
background: var(--bg-surface);
|
||||
|
|
@ -104,50 +111,6 @@
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 운영 콘솔은 생성 시안처럼 어두운 크롬을 쓴다. 본문 컴포넌트 토큰은 그대로 유지한다. */
|
||||
body[data-role="admin"] .vg-topbar {
|
||||
background: #17211f;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__wm,
|
||||
body[data-role="admin"] .vg-topbar__role,
|
||||
body[data-role="admin"] .vg-topbar__uname {
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__brand svg {
|
||||
color: #7eb8ad;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__user {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__switch {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__switch-link {
|
||||
color: rgba(238, 244, 242, 0.72);
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__switch-link:hover,
|
||||
body[data-role="admin"] .vg-topbar__switch-link.is-active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__avatar {
|
||||
background: rgba(126, 184, 173, 0.18);
|
||||
color: #bfe0d9;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__role {
|
||||
border-left-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
body[data-role="admin"] .vg-iconbtn {
|
||||
color: rgba(238, 244, 242, 0.78);
|
||||
}
|
||||
body[data-role="admin"] .vg-iconbtn:hover {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 톱바 아이콘 버튼 (테마/로그아웃) */
|
||||
.vg-iconbtn {
|
||||
display: inline-flex;
|
||||
|
|
@ -216,6 +179,9 @@ body[data-role="admin"] .vg-iconbtn:hover {
|
|||
--nav-cur: var(--nav-w);
|
||||
display: grid;
|
||||
grid-template-columns: var(--nav-cur) 1fr;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
align-items: start;
|
||||
/* 전체 높이 사이드바 구분선: sticky 네비(뷰포트 높이)에 의존하지 않고
|
||||
본문 그리드 전체 높이를 따라가는 컨테이너 배경 하어라인. */
|
||||
|
|
@ -231,10 +197,12 @@ body[data-role="admin"] .vg-iconbtn:hover {
|
|||
|
||||
/* ── 좌측 네비 ── */
|
||||
.vg-nav {
|
||||
position: sticky;
|
||||
top: var(--topbar-h);
|
||||
position: relative;
|
||||
top: auto;
|
||||
align-self: start;
|
||||
height: calc(100vh - var(--topbar-h));
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--bg-surface);
|
||||
/* 구분선은 .vg-shell__body 컨테이너 배경(전체 높이)이 그린다. */
|
||||
padding: var(--sp-5) var(--sp-3);
|
||||
|
|
@ -299,86 +267,15 @@ body[data-role="admin"] .vg-iconbtn:hover {
|
|||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
body[data-role="admin"] .vg-shell__body {
|
||||
background-image: linear-gradient(rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
body[data-role="admin"] .vg-nav {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(23, 33, 31, 0.98), rgba(26, 38, 42, 0.98)),
|
||||
var(--asset-warm-elements) center / cover no-repeat;
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__label,
|
||||
body[data-role="admin"] .vg-nav__ethic {
|
||||
color: rgba(238, 244, 242, 0.54);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item {
|
||||
color: rgba(238, 244, 242, 0.76);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item .vg-nav__ic {
|
||||
color: rgba(191, 224, 217, 0.72);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item.is-active {
|
||||
background: rgba(126, 184, 173, 0.22);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item.is-active .vg-nav__ic {
|
||||
color: #91c8bd;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__foot {
|
||||
border-top-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
body[data-role="instructor"] .vg-shell__body {
|
||||
--nav-cur: 108px;
|
||||
background-image: linear-gradient(rgba(38, 88, 83, 0.16), rgba(38, 88, 83, 0.16));
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(32, 107, 99, 0.98), rgba(22, 83, 79, 0.98)),
|
||||
var(--asset-warm-elements) center / cover no-repeat;
|
||||
color: #eef4f2;
|
||||
padding: var(--sp-5) 10px;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__label,
|
||||
body[data-role="instructor"] .vg-nav__ethic {
|
||||
color: rgba(238, 244, 242, 0.62);
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__item {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 64px;
|
||||
padding: 9px 8px;
|
||||
color: rgba(238, 244, 242, 0.82);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__item .vg-nav__ic {
|
||||
color: rgba(218, 242, 238, 0.78);
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__item.is-active {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__item.is-active .vg-nav__ic {
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__foot {
|
||||
border-top-color: rgba(255, 255, 255, 0.13);
|
||||
}
|
||||
|
||||
/* ── 메인 콘텐츠 ── */
|
||||
.vg-main {
|
||||
min-width: 0; /* 그리드 자식 overflow 방지 */
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: contain;
|
||||
scrollbar-gutter: stable;
|
||||
padding: var(--sp-7) var(--sp-6) var(--sp-8);
|
||||
}
|
||||
.vg-main__inner {
|
||||
|
|
@ -402,9 +299,6 @@ body[data-role="instructor"] .vg-nav__foot {
|
|||
/* 그리드·구분선이 함께 축소 폭을 따른다 */
|
||||
--nav-cur: var(--nav-w-collapsed);
|
||||
}
|
||||
body[data-role="instructor"] .vg-shell__body {
|
||||
--nav-cur: var(--nav-w-collapsed);
|
||||
}
|
||||
.vg-shell__body--bare {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
@ -427,15 +321,17 @@ body[data-role="instructor"] .vg-nav__foot {
|
|||
scroll-padding-bottom: var(--sp-6);
|
||||
}
|
||||
.vg-shell__body {
|
||||
display: block;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: 58px minmax(0, 1fr);
|
||||
background-image: none; /* 네비가 상단 가로바로 전환 — 세로 구분선 제거 */
|
||||
}
|
||||
.vg-shell__body--bare {
|
||||
display: block;
|
||||
}
|
||||
.vg-nav {
|
||||
position: sticky;
|
||||
top: var(--topbar-h);
|
||||
position: relative;
|
||||
top: auto;
|
||||
z-index: 29;
|
||||
height: 58px;
|
||||
padding: 7px max(12px, env(safe-area-inset-left)) 7px max(12px, env(safe-area-inset-right));
|
||||
|
|
@ -490,186 +386,196 @@ body[data-role="instructor"] .vg-nav__foot {
|
|||
.vg-main {
|
||||
padding: var(--sp-5) var(--sp-4) var(--sp-7);
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar {
|
||||
background: #1d7169;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__wm,
|
||||
body[data-role="instructor"] .vg-topbar__role,
|
||||
body[data-role="instructor"] .vg-topbar__uname,
|
||||
body[data-role="instructor"] .vg-iconbtn {
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__wm .v,
|
||||
body[data-role="instructor"] .vg-topbar__mark {
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__brand svg {
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__role {
|
||||
border-left-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__user {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__avatar {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.vg-main--bleed {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Unified role chrome
|
||||
역할별 제품처럼 보이던 톱바/사이드바 변형을 하나의 앱 크롬으로 통합한다.
|
||||
역할 차이는 tokens.css의 accent만 사용하고, 형태·밀도·배경 체계는 동일하게 유지한다. */
|
||||
body[data-role="admin"] .vg-topbar,
|
||||
body[data-role="instructor"] .vg-topbar {
|
||||
background: var(--bg-surface);
|
||||
border-bottom-color: var(--hair);
|
||||
/* ── Botanical glass canvas ── */
|
||||
body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
}
|
||||
body[data-role] .vg-shell--botanical .vg-nav,
|
||||
body[data-role] .vg-shell--botanical .vg-main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.vg-shell--botanical .vg-main:not(.vg-main--bleed),
|
||||
.vg-shell--learner-dashboard .vg-main:not(.vg-main--bleed) {
|
||||
background: transparent;
|
||||
}
|
||||
/* 학습 대시보드 전용 규칙은 콘텐츠 패딩만 소유한다. 앱 크롬은 아래 SSOT를 그대로 쓴다. */
|
||||
.vg-shell--learner-dashboard .vg-main:not(.vg-main--bleed) {
|
||||
padding: 36px 32px 72px;
|
||||
background: transparent;
|
||||
}
|
||||
/* ── App chrome authority (파일 최종 우선순위) ── */
|
||||
.vg-shell--botanical {
|
||||
--topbar-h: 64px;
|
||||
--nav-w: 230px;
|
||||
}
|
||||
body[data-role] .vg-shell--botanical .vg-topbar {
|
||||
height: var(--topbar-h);
|
||||
background: color-mix(in srgb, var(--bg-surface) 76%, transparent);
|
||||
border-bottom-color: var(--botanical-hair);
|
||||
color: var(--text-strong);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
[data-theme="dark"] body[data-role] .vg-shell--botanical .vg-topbar {
|
||||
background: color-mix(in srgb, var(--bg-app) 76%, transparent);
|
||||
}
|
||||
body[data-role] .vg-shell--botanical .vg-topbar__wm,
|
||||
body[data-role] .vg-shell--botanical .vg-topbar__role,
|
||||
body[data-role] .vg-shell--botanical .vg-topbar__uname,
|
||||
body[data-role] .vg-shell--botanical .vg-iconbtn {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__wm,
|
||||
body[data-role="admin"] .vg-topbar__role,
|
||||
body[data-role="admin"] .vg-topbar__uname,
|
||||
body[data-role="admin"] .vg-iconbtn,
|
||||
body[data-role="instructor"] .vg-topbar__wm,
|
||||
body[data-role="instructor"] .vg-topbar__role,
|
||||
body[data-role="instructor"] .vg-topbar__uname,
|
||||
body[data-role="instructor"] .vg-iconbtn {
|
||||
color: var(--text-strong);
|
||||
body[data-role] .vg-shell--botanical .vg-topbar__wm {
|
||||
font-size: 19px;
|
||||
font-weight: 760;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__wm .v,
|
||||
body[data-role="instructor"] .vg-topbar__wm .v,
|
||||
body[data-role="admin"] .vg-topbar__mark,
|
||||
body[data-role="instructor"] .vg-topbar__mark,
|
||||
body[data-role="admin"] .vg-topbar__brand svg,
|
||||
body[data-role="instructor"] .vg-topbar__brand svg {
|
||||
color: var(--accent);
|
||||
body[data-role] .vg-shell--botanical .vg-topbar__role {
|
||||
border-left-color: var(--botanical-hair);
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__role,
|
||||
body[data-role="instructor"] .vg-topbar__role {
|
||||
border-left-color: var(--hair);
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__user,
|
||||
body[data-role="instructor"] .vg-topbar__user {
|
||||
body[data-role] .vg-shell--botanical .vg-topbar__user {
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__avatar,
|
||||
body[data-role="instructor"] .vg-topbar__avatar {
|
||||
body[data-role] .vg-shell--botanical .vg-topbar__avatar {
|
||||
background: var(--accent-tint);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
body[data-role="admin"] .vg-iconbtn:hover,
|
||||
body[data-role="instructor"] .vg-iconbtn:hover {
|
||||
background: var(--bg-surface-2);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
body[data-role="admin"] .vg-shell__body,
|
||||
body[data-role="instructor"] .vg-shell__body {
|
||||
body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
|
||||
--nav-cur: var(--nav-w);
|
||||
background-image: linear-gradient(var(--hair), var(--hair));
|
||||
background-color: var(--glass-canvas);
|
||||
background-image:
|
||||
linear-gradient(var(--botanical-hair), var(--botanical-hair)),
|
||||
radial-gradient(circle at 68% 18%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 34rem),
|
||||
radial-gradient(circle at 42% 72%, color-mix(in srgb, var(--botanical-gold) 13%, transparent), transparent 42rem),
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--bg-app) 28%, transparent), color-mix(in srgb, var(--bg-app) 62%, transparent)),
|
||||
var(--asset-botanical-corner),
|
||||
radial-gradient(circle at 76% 10%, var(--botanical-glow), transparent 30rem);
|
||||
background-repeat: no-repeat;
|
||||
background-position: var(--nav-cur) 0, center top, center bottom, center top, right top, center top;
|
||||
background-size: 1px 100%, 100% 52rem, 100% 62rem, 100% 46rem, min(66vw, 980px) auto, 100% 46rem;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav,
|
||||
body[data-role="instructor"] .vg-nav {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-strong);
|
||||
body[data-role] .vg-shell--botanical .vg-nav {
|
||||
top: auto;
|
||||
padding: var(--sp-5) var(--sp-3);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--bg-surface) 34%, transparent), color-mix(in srgb, var(--bg-app) 54%, transparent)),
|
||||
var(--asset-botanical-sidebar) center bottom / auto 72% no-repeat,
|
||||
color-mix(in srgb, var(--bg-surface) 62%, transparent);
|
||||
color: var(--text-strong);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__label,
|
||||
body[data-role="admin"] .vg-nav__ethic,
|
||||
body[data-role="instructor"] .vg-nav__label,
|
||||
body[data-role="instructor"] .vg-nav__ethic {
|
||||
body[data-role] .vg-shell--botanical .vg-nav__label,
|
||||
body[data-role] .vg-shell--botanical .vg-nav__ethic {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item,
|
||||
body[data-role="instructor"] .vg-nav__item {
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
min-height: 50px;
|
||||
padding: 10px 12px;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
gap: 11px;
|
||||
min-height: 0;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-body);
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
text-align: left;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item:hover,
|
||||
body[data-role="instructor"] .vg-nav__item:hover {
|
||||
background: var(--bg-surface-2);
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item:hover {
|
||||
border-color: color-mix(in srgb, var(--botanical-gold) 18%, var(--border-subtle));
|
||||
background: color-mix(in srgb, var(--bg-surface) 86%, var(--bg-tint));
|
||||
color: var(--text-strong);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item .vg-nav__ic,
|
||||
body[data-role="instructor"] .vg-nav__item .vg-nav__ic {
|
||||
color: var(--text-muted);
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
|
||||
padding-right: 58px;
|
||||
border-color: color-mix(in srgb, var(--botanical-gold) 34%, var(--border-subtle));
|
||||
background: linear-gradient(
|
||||
112deg,
|
||||
color-mix(in srgb, var(--bg-surface) 94%, var(--botanical-gold)),
|
||||
color-mix(in srgb, var(--bg-surface-2) 78%, transparent)
|
||||
);
|
||||
color: var(--text-strong);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg-surface) 84%, transparent);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item.is-active,
|
||||
body[data-role="instructor"] .vg-nav__item.is-active {
|
||||
background: var(--accent-tint);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item.is-active .vg-nav__ic,
|
||||
body[data-role="instructor"] .vg-nav__item.is-active .vg-nav__ic {
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item.is-active .vg-nav__ic {
|
||||
color: var(--accent);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__foot,
|
||||
body[data-role="instructor"] .vg-nav__foot {
|
||||
border-top-color: var(--hair);
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item.is-active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 58px;
|
||||
height: 100%;
|
||||
background: var(--asset-botanical-leaf) right -20px bottom -42px / 76px 96px no-repeat;
|
||||
opacity: 0.28;
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
}
|
||||
body[data-role] .vg-shell--botanical .vg-nav__foot {
|
||||
border-top-color: var(--botanical-hair);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
body[data-role="admin"] .vg-shell__body,
|
||||
body[data-role="instructor"] .vg-shell__body {
|
||||
body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
|
||||
--nav-cur: var(--nav-w-collapsed);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav,
|
||||
body[data-role="instructor"] .vg-nav {
|
||||
body[data-role] .vg-shell--botanical .vg-nav {
|
||||
padding: var(--sp-4) var(--sp-2);
|
||||
background-image: none;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item,
|
||||
body[data-role="instructor"] .vg-nav__item {
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item,
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
|
||||
min-height: 46px;
|
||||
justify-content: center;
|
||||
padding: 11px;
|
||||
}
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item.is-active::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
body[data-role="admin"] .vg-shell__body,
|
||||
body[data-role="instructor"] .vg-shell__body {
|
||||
display: block;
|
||||
background-image: none;
|
||||
body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: 58px minmax(0, 1fr);
|
||||
background-image:
|
||||
radial-gradient(circle at 74% 12%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 22rem),
|
||||
radial-gradient(circle at 18% 82%, color-mix(in srgb, var(--botanical-gold) 14%, transparent), transparent 28rem),
|
||||
var(--asset-botanical-corner);
|
||||
background-repeat: no-repeat;
|
||||
background-position: right top, left bottom, right -140px bottom -80px;
|
||||
background-size: 100% 34rem, 100% 38rem, 430px auto;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav,
|
||||
body[data-role="instructor"] .vg-nav {
|
||||
position: sticky;
|
||||
top: var(--topbar-h);
|
||||
body[data-role] .vg-shell--botanical .vg-nav {
|
||||
position: relative;
|
||||
top: auto;
|
||||
z-index: 29;
|
||||
height: 58px;
|
||||
padding: 7px max(12px, env(safe-area-inset-left)) 7px max(12px, env(safe-area-inset-right));
|
||||
border-bottom: 1px solid var(--hair);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
border-bottom: 1px solid var(--botanical-hair);
|
||||
background: color-mix(in srgb, var(--bg-surface) 72%, transparent);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item,
|
||||
body[data-role="instructor"] .vg-nav__item {
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item,
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
|
||||
min-width: 112px;
|
||||
height: 44px;
|
||||
justify-content: center;
|
||||
padding: 0 14px;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item span:not(.vg-nav__ic),
|
||||
body[data-role="instructor"] .vg-nav__item span:not(.vg-nav__ic) {
|
||||
display: inline;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { Surface } from "./Surface";
|
||||
|
||||
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 그림자 제거(헤어라인만) */
|
||||
|
|
@ -10,17 +11,9 @@ export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
|||
|
||||
/** 카드: 헤어라인 + 미세 그림자. 상단 강조선 금지. radius 8px. §7.3 */
|
||||
export function Card({ flat, tint, className, children, ...rest }: CardProps) {
|
||||
const cls = [
|
||||
"vg-card",
|
||||
flat ? "vg-card--flat" : "",
|
||||
tint ? "vg-card--tint" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<div className={cls} {...rest}>
|
||||
<Surface className={["vg-card", className ?? ""].filter(Boolean).join(" ")} flat={flat} tint={tint} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
26
apps/web/src/components/ui/EmptyState.tsx
Normal file
26
apps/web/src/components/ui/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export interface EmptyStateProps extends Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"title"
|
||||
> {
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
descriptionAs?: "span" | "p" | "div";
|
||||
}
|
||||
|
||||
/** 데이터 없음·준비 중 상태의 공통 의미 구조. 배치와 표면은 호출 화면이 소유한다. */
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
descriptionAs: Description = "span",
|
||||
className = "vg-empty",
|
||||
...rest
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className={className} {...rest}>
|
||||
<b className="vg-empty__title">{title}</b>
|
||||
<Description className="vg-empty__desc">{description}</Description>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { Surface } from "./Surface";
|
||||
|
||||
export interface PanelProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 그림자 제거 */
|
||||
|
|
@ -10,17 +11,9 @@ export interface PanelProps extends HTMLAttributes<HTMLDivElement> {
|
|||
|
||||
/** 패널: 카드보다 큰 radius(12px) + 넉넉한 패딩(32px). §7.3 */
|
||||
export function Panel({ flat, tint, className, children, ...rest }: PanelProps) {
|
||||
const cls = [
|
||||
"vg-panel",
|
||||
flat ? "vg-panel--flat" : "",
|
||||
tint ? "vg-panel--tint" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<div className={cls} {...rest}>
|
||||
<Surface className={["vg-panel", className ?? ""].filter(Boolean).join(" ")} flat={flat} tint={tint} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Kicker } from "./Kicker";
|
||||
|
||||
export interface SectionHeadProps {
|
||||
/** kicker 텍스트 (상단 소제목) */
|
||||
kicker?: ReactNode;
|
||||
/** kicker dot 표시 */
|
||||
dot?: boolean;
|
||||
/** 섹션 제목 (h2) */
|
||||
title?: ReactNode;
|
||||
/** 보조 설명 */
|
||||
desc?: ReactNode;
|
||||
/** 제목 우측 액션 영역 */
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** SectionHead — kicker + 제목 + 설명. 한 화면 한 메시지의 위계 헤더. */
|
||||
export function SectionHead({
|
||||
kicker,
|
||||
dot = true,
|
||||
title,
|
||||
desc,
|
||||
action,
|
||||
className,
|
||||
}: SectionHeadProps) {
|
||||
const cls = ["vg-sechead", className ?? ""].filter(Boolean).join(" ");
|
||||
return (
|
||||
<div className={cls}>
|
||||
{kicker ? (
|
||||
<div className="vg-sechead__kicker">
|
||||
<Kicker dot={dot}>{kicker}</Kicker>
|
||||
</div>
|
||||
) : null}
|
||||
{(title || action) && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: "var(--sp-4)",
|
||||
}}
|
||||
>
|
||||
{title ? <h2 className="vg-sechead__title">{title}</h2> : <span />}
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
{desc ? <div className="vg-sechead__desc">{desc}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { Fragment } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface Stat {
|
||||
/** 숫자/값 (tabular 표기) */
|
||||
num: ReactNode;
|
||||
/** 라벨 */
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
export interface StatLineProps {
|
||||
stats: Stat[];
|
||||
/** 항목 사이 세로 구분선 */
|
||||
separators?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** StatLine — 요약 스탯 가로 나열. 숫자는 tabular-nums. */
|
||||
export function StatLine({ stats, separators = true, className }: StatLineProps) {
|
||||
const cls = ["vg-statline", className ?? ""].filter(Boolean).join(" ");
|
||||
return (
|
||||
<div className={cls}>
|
||||
{stats.map((s, i) => (
|
||||
<Fragment key={i}>
|
||||
{separators && i > 0 ? (
|
||||
<span className="vg-statline__sep" aria-hidden="true" />
|
||||
) : null}
|
||||
<span className="vg-statline__item">
|
||||
<span className="vg-statline__num tabular">{s.num}</span>
|
||||
<span className="vg-statline__lab">{s.label}</span>
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
apps/web/src/components/ui/Surface.tsx
Normal file
52
apps/web/src/components/ui/Surface.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type SurfaceVariant = "panel" | "inset" | "interactive";
|
||||
|
||||
export interface SurfaceOptions {
|
||||
variant?: SurfaceVariant;
|
||||
flat?: boolean;
|
||||
tint?: boolean;
|
||||
}
|
||||
|
||||
/** raw element(button/li/section 포함)도 같은 표면 계약을 쓰게 하는 class builder. */
|
||||
export function surfaceClassName(
|
||||
className?: string,
|
||||
{ variant = "panel", flat, tint }: SurfaceOptions = {},
|
||||
) {
|
||||
return [
|
||||
"vg-surface",
|
||||
`vg-surface--${variant}`,
|
||||
flat ? "vg-surface--flat" : "",
|
||||
tint ? "vg-surface--tint" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export interface SurfaceProps extends HTMLAttributes<HTMLDivElement>, SurfaceOptions {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface — 배경·헤어라인·그림자·backdrop-filter의 단일 소유자.
|
||||
* 페이지 CSS는 이 컴포넌트의 배치와 내부 타이포만 소유한다.
|
||||
*/
|
||||
export function Surface({
|
||||
variant = "panel",
|
||||
flat,
|
||||
tint,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: SurfaceProps) {
|
||||
return (
|
||||
<div
|
||||
className={surfaceClassName(className, { variant, flat, tint })}
|
||||
data-surface={variant}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,11 +13,14 @@ export type { CardProps } from "./Card";
|
|||
export { Panel } from "./Panel";
|
||||
export type { PanelProps } from "./Panel";
|
||||
|
||||
export { Surface, surfaceClassName } from "./Surface";
|
||||
export type { SurfaceProps, SurfaceOptions, SurfaceVariant } from "./Surface";
|
||||
|
||||
export { Kicker } from "./Kicker";
|
||||
export type { KickerProps } from "./Kicker";
|
||||
|
||||
export { SectionHead } from "./SectionHead";
|
||||
export type { SectionHeadProps } from "./SectionHead";
|
||||
export { EmptyState } from "./EmptyState";
|
||||
export type { EmptyStateProps } from "./EmptyState";
|
||||
|
||||
export { Badge } from "./Badge";
|
||||
export type { BadgeProps, BadgeTone } from "./Badge";
|
||||
|
|
@ -25,9 +28,6 @@ export type { BadgeProps, BadgeTone } from "./Badge";
|
|||
export { Dot } from "./Dot";
|
||||
export type { DotProps, DotTone } from "./Dot";
|
||||
|
||||
export { StatLine } from "./StatLine";
|
||||
export type { StatLineProps, Stat } from "./StatLine";
|
||||
|
||||
export { ProgressBar } from "./ProgressBar";
|
||||
export type { ProgressBarProps, ProgressTone } from "./ProgressBar";
|
||||
|
||||
|
|
|
|||
|
|
@ -83,16 +83,18 @@
|
|||
}
|
||||
|
||||
.vg-btn--primary {
|
||||
background: var(--accent);
|
||||
background: linear-gradient(105deg, color-mix(in srgb, var(--accent) 90%, var(--bg-surface)), var(--accent));
|
||||
color: var(--text-on-accent);
|
||||
border-color: color-mix(in srgb, var(--botanical-gold) 24%, var(--accent));
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg-surface) 32%, transparent);
|
||||
}
|
||||
.vg-btn--primary:hover:not(:disabled) {
|
||||
background: var(--accent-deep);
|
||||
}
|
||||
.vg-btn--secondary {
|
||||
background: var(--bg-surface);
|
||||
background: var(--surface-gradient);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 62%, var(--botanical-hair));
|
||||
}
|
||||
.vg-btn--secondary:hover:not(:disabled) {
|
||||
background: var(--accent-tint);
|
||||
|
|
@ -107,42 +109,61 @@
|
|||
}
|
||||
.vg-btn--danger {
|
||||
background: var(--crit-solid);
|
||||
color: #fff;
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
.vg-btn--danger:hover:not(:disabled) {
|
||||
background: var(--crit-text);
|
||||
}
|
||||
|
||||
/* ── Card / Panel §7.3 (헤어라인 + 미세 그림자, 상단 강조선 금지) ── */
|
||||
.vg-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
/* ── Surface SSOT ── */
|
||||
.vg-surface.vg-surface--panel {
|
||||
background: var(--glass-specular), var(--glass-surface);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-5);
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: var(--glass-edge-shadow), var(--glass-shadow);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
.vg-card--flat {
|
||||
.vg-surface.vg-surface--inset {
|
||||
background: var(--glass-specular-inset), var(--glass-surface-inset);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: var(--glass-inset-shadow);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(110%);
|
||||
backdrop-filter: blur(12px) saturate(110%);
|
||||
}
|
||||
.vg-surface.vg-surface--interactive {
|
||||
background: var(--glass-specular-inset), var(--glass-surface-inset);
|
||||
border: 1px solid var(--glass-inset-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-inset-shadow);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(110%);
|
||||
backdrop-filter: blur(12px) saturate(110%);
|
||||
transition:
|
||||
background var(--dur-base) var(--ease-out),
|
||||
border-color var(--dur-base) var(--ease-out),
|
||||
transform var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.vg-surface.vg-surface--interactive:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 48%, var(--glass-border));
|
||||
background: var(--glass-specular), var(--glass-surface-strong);
|
||||
}
|
||||
.vg-surface.vg-surface--flat {
|
||||
box-shadow: none;
|
||||
}
|
||||
.vg-card--tint {
|
||||
.vg-surface.vg-surface--tint {
|
||||
background: var(--bg-tint);
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── Card / Panel 크기 계약. 표면은 위 Surface만 소유한다. ── */
|
||||
.vg-card {
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
.vg-panel {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-6);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.vg-panel--flat {
|
||||
box-shadow: none;
|
||||
}
|
||||
.vg-panel--tint {
|
||||
background: var(--bg-tint);
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── Badge (배경 틴트 + 텍스트, 좌측바 없음) ── */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue