전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

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

View 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;
}
}

View file

@ -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>

View file

@ -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"

View file

@ -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;
/**

View file

@ -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}

View file

@ -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" },

View file

@ -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 ? "라이트 모드" : "다크 모드"}
>

View file

@ -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;
}
}

View file

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

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

View file

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

View file

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

View file

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

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

View file

@ -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";

View file

@ -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 (배경 틴트 + 텍스트, 좌측바 없음) ── */