feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG

web (Vite+React19+TS, Cloudflare Pages 배포):
- 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브
- 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정
- ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서
- 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨

api (FastAPI):
- 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존)
- services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 /
  턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG
- store: DB off 폴백(in-memory), sessions 실구현

검증:
- web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200
- api: app.main import 통과
- 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시)
- E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
Yun Chan 2026-06-25 23:37:22 +09:00
parent 859ab26314
commit 24b1b7a6e1
84 changed files with 19645 additions and 107 deletions

View file

@ -0,0 +1,45 @@
/* =====================================================================
AuraLayer (aura)
§4.4: 광배 opacity ±0.04~0.08, 5~6s. speaking ().
§4.5: 정서별 (// / ).
transform/opacity . radial-gradient .
Rive 2 SVG/CSS ( ).
===================================================================== */
import type { AvatarState } from "./persona";
interface AuraLayerProps {
/** 광배 색조 (CSS 색) */
hue: string;
/** 기본 opacity */
opacity: number;
/** 채도 가중(연령대 생기) */
saturation: number;
state: AvatarState;
/** speaking 시 입 열림(0~1) — 광배 미세 약동 동기 */
mouth: number;
/** reduced-motion 이면 펄스 정지 */
reduced: boolean;
}
export function AuraLayer({ hue, opacity, saturation, state, mouth, reduced }: AuraLayerProps) {
// speaking 일 때만 입 열림에 미세 동기(0.04~0.12 범위 추가). 그 외엔 CSS 호흡 펄스.
const speakBoost = state === "speaking" ? mouth * 0.06 : 0;
const finalOpacity = Math.min(0.16, (opacity + speakBoost) * saturation);
return (
<span
className={"vg-avatar__aura" + (reduced ? " is-reduced" : "")}
aria-hidden="true"
style={{
// 색 면적은 작게(68% 안쪽에서 사라짐) — 산만 방지
background: `radial-gradient(circle, color-mix(in srgb, ${hue} ${Math.round(
finalOpacity * 100,
)}%, transparent) 0%, transparent 68%)`,
// speaking 일 때 호흡 펄스를 살짝 빠르게(발화 리듬감)
animationDuration: state === "speaking" ? "5s" : "6s",
}}
/>
);
}

View file

@ -0,0 +1,35 @@
/* =====================================================================
BodySilhouette ~ ( X, )
§4.5: 자세() . = (turn 8°).
§4.4: 어깨 (translateY 1.5~2.5px). transform <g> .
Rive 2 / .riv .
===================================================================== */
interface BodySilhouetteProps {
/** 헤어/의상 실루엣 색 (1차는 hair.color 재사용) */
color: string;
/** 피부톤 (목) */
skin: string;
/** 어깨 돌아선 각도 deg (이미 부모 회전에 일부 반영, 여기선 비대칭만) */
shoulderTurn: number;
}
export function BodySilhouette({ color, skin, shoulderTurn }: BodySilhouetteProps) {
// 저항(turn)일수록 어깨 한쪽을 살짝 앞으로 — 비대칭으로 "돌아선" 느낌(과장 금지)
const skew = Math.min(8, shoulderTurn) * 0.6; // 최대 ~4.8 단위
return (
<>
{/* 어깨/몸 실루엣 — 한쪽 어깨를 turn 만큼 미세 비대칭 */}
<path
d={`M${40 - skew} 200
C${40 - skew} 158 64 142 100 142
C136 142 ${160 + skew} 158 ${160 + skew} 200 Z`}
fill={color}
opacity={0.9}
/>
{/* 목 */}
<rect x="88" y="118" width="24" height="26" rx="10" fill={skin} />
</>
);
}

View file

@ -0,0 +1,43 @@
/* =====================================================================
Brows ( 2~3px)
§4.5: 눈썹 () / () / () / ().
1~3px .
Rive 2 brow / input .
===================================================================== */
interface BrowsProps {
/** 안쪽 끝 기울기 px (양수=안쪽 처짐/우울, 음수=안쪽 올라/긴장) */
browTilt: number;
/** 눈썹 색 (헤어 색) */
color: string;
}
export function Brows({ browTilt, color }: BrowsProps) {
// 안쪽 끝 y 를 browTilt 만큼 이동(좌우 대칭). 바깥 끝은 고정.
const innerY = 79 + browTilt;
return (
<>
{/* 왼 눈썹: 바깥(74) → 안쪽(90) */}
<line
x1="74"
y1="80"
x2="90"
y2={innerY}
stroke={color}
strokeWidth="2.4"
strokeLinecap="round"
/>
{/* 오른 눈썹: 안쪽(110) → 바깥(126) */}
<line
x1="110"
y1={innerY}
x2="126"
y2="80"
stroke={color}
strokeWidth="2.4"
strokeLinecap="round"
/>
</>
);
}

View file

@ -0,0 +1,248 @@
/* =====================================================================
ClientAvatar ( )
출처: docs/DESIGN_CONCEPT.md §4 (4.1~4.7)
"사실성 35~45% 양식화 반구상 흉상 + 호흡하는 광배." 30~50
( 2),
(·· , ), "감지되되 단언되지 않게".
(§4.7): SVG + CSS/Web Animations + Web Audio (1).
- rAF useAvatarMotion: 호흡 + + (RMS) + saccade
- 6 (persona.ts): eyelidDrop/gazeAvert/shoulderTurn/
breathRate/auraHue/blinkInterval. 8°0° ( ).
- transform/opacity (layout ). 2 .
- prefers-reduced-motion: 호흡/ + .
- analyser=null: / .
Rive 2
.riv ,
(persona / state / affect / analyser / rapport)
<svg>+useAvatarMotion <RiveAvatar> .
AffectParams(persona.ts) .riv state-machine input wiring.
===================================================================== */
import { useEffect, useMemo, useState } from "react";
import {
ageLookFor,
baseResistanceOf,
resolveAffectParams,
type AvatarAffect,
type AvatarPersona,
type AvatarState,
} from "./persona";
import { useAvatarMotion } from "./useAvatarMotion";
import { AuraLayer } from "./AuraLayer";
import { BodySilhouette } from "./BodySilhouette";
import { Eyes } from "./Eyes";
import { Brows } from "./Brows";
import { Mouth } from "./Mouth";
/* ( import )
Session.tsx ClientAvatar . */
export type { AvatarState, AvatarAffect, AvatarPersona } from "./persona";
export interface ClientAvatarProps {
persona: AvatarPersona;
state: AvatarState;
affect: AvatarAffect;
/** speaking 립싱크용 Web Audio AnalyserNode (없으면 텍스트/타이핑 폴백) */
analyser?: AnalyserNode | null;
/**
* 0~1 (§4.5 ). .
* shoulderTurn 8°0°, gazeAvert .
*/
rapport?: number;
/**
* analyser 0~1.
* null/ speaking .
*/
speakingProgress?: number | null;
/** px 지름 (기본 220 — §5.2 아바타 220px) */
size?: number;
className?: string;
}
function prefersReducedMotion(): boolean {
return (
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true
);
}
/** prefers-reduced-motion 을 반응형으로 구독 */
function useReducedMotion(): boolean {
const [reduced, setReduced] = useState<boolean>(prefersReducedMotion);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = () => setReduced(mq.matches);
mq.addEventListener?.("change", onChange);
return () => mq.removeEventListener?.("change", onChange);
}, []);
return reduced;
}
const STATE_TEXT: Record<AvatarState, string> = {
idle: "대기 중",
listening: "당신의 말을 듣는 중",
thinking: "잠시 생각하는 중",
speaking: "이야기하는 중",
};
export function ClientAvatar({
persona,
state,
affect,
analyser = null,
rapport = 0,
speakingProgress = null,
size = 220,
className,
}: ClientAvatarProps) {
const reduced = useReducedMotion();
// 외형 안전값
const skin = persona.skinTone;
const hairColor = persona.hair.color;
const realism = Math.min(0.45, Math.max(0.3, persona.realism)); // 안전 클램프
const age = ageLookFor(persona.ageBand);
// 6파라미터 정서 → 라포/상태 반영 (라포는 baseResistance 와 합성)
const baseRes = baseResistanceOf(persona, affect);
// 라포가 저항을 푼다: 실효 라포 = rapport (저항 페르소나일 때 더 큰 시각 효과)
const effectiveRapport = baseRes > 0 ? Math.min(1, rapport) : rapport * 0.4;
const params = useMemo(
() => resolveAffectParams(affect, state, effectiveRapport),
[affect, state, effectiveRapport],
);
// 모션 루프 (reduced 면 정지)
const frame = useAvatarMotion({
state,
params,
analyser,
speakingProgress,
enabled: !reduced,
});
// reduced-motion: 정적 프레임(호흡 0, 눈 뜸, 입 닫힘)
const breath = reduced ? 0 : frame.breath;
const blink = reduced ? 1 : frame.blink;
const mouth = reduced ? 0 : frame.mouth;
const saccadeX = reduced ? 0 : frame.saccadeX;
const saccadeY = reduced ? 0 : frame.saccadeY;
// 시선 = 정서 회피(gazeAvert) + saccade 미세 이동
const gazeX = -params.gazeAvert + saccadeX;
const gazeY = saccadeY;
// 홍채 색: 헤어보다 약간 진한 잉크(순흑 금지 → ink 톤)
const irisColor = "#2B2B2B";
// 흉상 회전: 어깨 돌아선 각도(저항). 회전 중심 = 어깨 위.
const shoulderRotate = params.shoulderTurn * 0.4;
return (
<figure
className={"vg-avatar" + (className ? " " + className : "")}
style={{ width: size }}
data-state={state}
data-affect={affect}
data-realism={realism} /* 사실성은 데이터로만 유지(시각 표식 비노출, §4.6) */
aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}`}
>
<style>{AVATAR_CSS}</style>
{/* 상단 라벨 — 실존 인물 오인 차단(상시, §4.1) */}
<figcaption className="vg-avatar__label">
<span className="vg-avatar__label-dot" aria-hidden="true" />
</figcaption>
<div className="vg-avatar__stage" style={{ height: size }}>
{/* 호흡하는 광배 */}
<AuraLayer
hue={params.auraHue}
opacity={params.auraOpacity}
saturation={age.auraSaturation}
state={state}
mouth={mouth}
reduced={reduced}
/>
<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)`}>
{/* 어깨/목 실루엣 */}
<BodySilhouette color={hairColor} skin={skin} shoulderTurn={params.shoulderTurn} />
{/* 머리 (양식화 — 코·주름·모공 없음) */}
<ellipse cx="100" cy="92" rx={42 * age.jawWidth} ry="46" fill={skin} />
{/* 헤어 (단순 캡 형태) */}
<path
d="M58 92 C58 58 78 44 100 44 C122 44 142 58 142 92 C142 78 128 70 100 70 C72 70 58 78 58 92 Z"
fill={hairColor}
/>
{/* 얼굴 그룹: 시선 회피/saccade (translate) */}
<g transform={`translate(${gazeX} ${gazeY})`}>
<Brows browTilt={params.browTilt} color={hairColor} />
<Eyes
eyeSize={age.eyeSize}
blink={blink}
eyelidDrop={params.eyelidDrop}
irisColor={irisColor}
/>
<Mouth open={mouth} curve={params.mouthCurve} color="#9B5B52" />
</g>
</g>
</svg>
</div>
{/* 페르소나 메타 + 상태 텍스트 */}
<div className="vg-avatar__meta">
<span className="vg-avatar__persona">{persona.label}</span>
<span className="vg-avatar__state">
: <b>{STATE_TEXT[state]}</b>
</span>
</div>
</figure>
);
}
const AVATAR_CSS = `
.vg-avatar{display:flex;flex-direction:column;align-items:center;gap:var(--sp-3);margin:0;}
.vg-avatar__label{
display:inline-flex;align-items:center;gap:7px;
font-family:var(--font-num);font-size:var(--fs-xs);font-weight:600;letter-spacing:0.06em;
color:var(--text-muted);text-transform:none;
}
.vg-avatar__label-dot{width:6px;height:6px;border-radius:50%;background:var(--clay);flex:none;}
.vg-avatar__stage{
position:relative;width:100%;border-radius:50%;
display:flex;align-items:center;justify-content:center;overflow:hidden;
background:var(--bg-stage);
}
.vg-avatar__aura{
position:absolute;inset:-12%;border-radius:50%;pointer-events:none;
animation:vgAuraBreathe 6s var(--ease-in-out) infinite;
}
.vg-avatar__aura.is-reduced{animation:none;}
@keyframes vgAuraBreathe{0%,100%{opacity:.85;transform:scale(1)}50%{opacity:1;transform:scale(1.04)}}
.vg-avatar__svg{position:relative;z-index:1;display:block;}
.vg-avatar__meta{display:flex;flex-direction:column;align-items:center;gap:3px;text-align:center;}
.vg-avatar__persona{font-size:var(--fs-sm);font-weight:600;color:var(--text-strong);}
.vg-avatar__state{font-size:var(--fs-xs);color:var(--text-muted);}
.vg-avatar__state b{font-weight:600;color:var(--text-body);}
@media (prefers-reduced-motion: reduce){
.vg-avatar__aura{animation:none;}
}
`;

View file

@ -0,0 +1,48 @@
/* =====================================================================
Eyes ( scaleY + gaze + )
§4.1: 흰자 /. ·· (35% ).
§4.4: 깜빡임 scaleY 10.081. §4.5: eyelidDrop(), gazeAvert().
Rive 2 /blink input .
===================================================================== */
interface EyesProps {
/** 눈 크기 배율 (연령대 §4.6) */
eyeSize: number;
/** 깜빡임 scaleY (1=뜸, 0.08=감김) */
blink: number;
/** 눈꺼풀 하강 px (우울) */
eyelidDrop: number;
/** 홍채 색(헤어보다 약간 진한 잉크) */
irisColor: string;
}
/** 단일 눈 — 아몬드 흰자 위에 작은 홍채. blink 는 scaleY 로만. */
function Eye({ cx, eyeSize, blink, eyelidDrop, irisColor }: {
cx: number;
eyeSize: number;
blink: number;
eyelidDrop: number;
irisColor: string;
}) {
return (
// 눈꺼풀 하강은 translateY, 깜빡임은 scaleY. (동시에 layout 속성 안 건드림)
<g transform={`translate(${cx} ${92 + eyelidDrop}) scale(${eyeSize} ${blink})`}>
{/* 아몬드(미세 흰자) — 양식화, 외곽선 없음 */}
<ellipse cx="0" cy="0" rx="7.5" ry="5.2" fill="#F3EEE6" opacity={0.7} />
{/* 홍채/동공 */}
<ellipse cx="0" cy="0" rx="4.6" ry="4.6" fill={irisColor} opacity={0.92} />
{/* 미세 하이라이트(생체감) */}
<circle cx="-1.4" cy="-1.4" r="1.1" fill="#FBFAF8" opacity={0.55} />
</g>
);
}
export function Eyes({ eyeSize, blink, eyelidDrop, irisColor }: EyesProps) {
return (
<>
<Eye cx={82} eyeSize={eyeSize} blink={blink} eyelidDrop={eyelidDrop} irisColor={irisColor} />
<Eye cx={118} eyeSize={eyeSize} blink={blink} eyelidDrop={eyelidDrop} irisColor={irisColor} />
</>
);
}

View file

@ -0,0 +1,51 @@
/* =====================================================================
Mouth (RMSscaleY + )
§4.3: 닫힘= , =. scaleY . · .
§4.5: 입꼬리(mouthCurve) / .
Rive 2 viseme/mouth-open input ( 1).
===================================================================== */
interface MouthProps {
/** 입 열림 0~1 (립싱크) */
open: number;
/** 입꼬리 각도 deg (양수=상향, 음수=하향) */
curve: number;
/** 입술 색 */
color: string;
}
export function Mouth({ open, curve, color }: MouthProps) {
// 닫힘=scaleY 0.18(얇은 호) → 열림=1.0(타원). §4.3 데드존은 모션 훅이 처리.
const scaleY = 0.18 + open * 0.82;
// 입꼬리: 미세 회전으로 표현(과장 금지, ±2° 내외)
const rot = Math.max(-2.2, Math.min(2.2, curve));
return (
<g transform={`translate(100 116) rotate(${-rot})`}>
{/* 입 안쪽(어두운 톤) — 열릴 때만 살짝 보임 */}
<ellipse
cx="0"
cy="0"
rx="11"
ry="5.5"
fill={color}
opacity={0.5}
style={{
transform: `scaleY(${scaleY})`,
transformOrigin: "center",
transition: "none", // rAF 가 직접 구동 — CSS transition 금지(이중 보간 방지)
}}
/>
{/* 윗입술 라인(얇은 호) — 닫혀도 입의 존재감 */}
<path
d="M-11 0 Q0 -1.5 11 0"
fill="none"
stroke={color}
strokeWidth="1.6"
strokeLinecap="round"
opacity={0.45}
/>
</g>
);
}

View file

@ -0,0 +1,202 @@
/* =====================================================================
ClientAvatar / + 6
출처: docs/DESIGN_CONCEPT.md §4.5(6 ) / §4.6( )
Rive 2
SVG 1 6 transform .
Rive : (state, affect, resistance, rapport)
, AffectParams .riv state-machine input wiring .
(ClientAvatar/useAvatarMotion) AffectParams .
===================================================================== */
/* ── 음성 UI 4-state (§4.2) ─────────────────────────────────────────── */
export type AvatarState = "idle" | "listening" | "thinking" | "speaking";
/* ── 내담자 정서 4종 (§4.5) ────────────────────────────────────────── */
export type AvatarAffect = "neutral" | "depressed" | "anxious" | "resistant";
/* ── 연령대 (§4.6 외형 규칙) ───────────────────────────────────────── */
export type AvatarAgeBand = "teen" | "youngAdult" | "adult" | "senior";
export interface AvatarPersona {
/** "서연 · 17세 · 고2" 같은 메타 1줄 라벨 */
label: string;
/** 연령대 (외형 규칙 §4.6) */
ageBand: AvatarAgeBand;
/** 피부톤 hex */
skinTone: string;
/** 헤어 (스타일 + 색) — 1차는 색만 사용 */
hair: { style?: string; color: string };
/** 사실성 0.35~0.45 고정 (불쾌한 골짜기 회피) */
realism: number;
/**
* 0~1 (§4.6). .
* affect="resistant" 0.6, 0 .
*/
resistance?: number;
}
/* 6 (§4.5)
6 :
(eyelidDrop, gazeAvert, shoulderTurn, breathRate, auraHue, blinkInterval)
/, min/max . */
export interface AffectParams {
/** 눈꺼풀 하강 px (우울 1~2px) */
eyelidDrop: number;
/** 시선 회피 x 오프셋 px (저항/사고 시) */
gazeAvert: number;
/** 어깨 돌아선 각도 deg (저항 8° → 라포로 0°) */
shoulderTurn: number;
/** 호흡 주기 초 (= breathRate 역수 표현) */
breathPeriod: number;
/** 호흡 진폭 px (translateY 1.5~2.5) */
breathAmp: number;
/** 광배 색조 (CSS 색/변수) */
auraHue: string;
/** 광배 기본 opacity */
auraOpacity: number;
/** 깜빡임 최소 간격 초 */
blinkMin: number;
/** 깜빡임 최대 간격 초 */
blinkMax: number;
/** 입꼬리 각도 deg (양수=상향, 음수=하향). 미세값만. */
mouthCurve: number;
/** 눈썹 안쪽 끝 기울기 px (우울=안쪽 살짝 올라/처짐) */
browTilt: number;
}
/* (§4.5)
금지: 눈물// . "감지되되 단언되지 않는" . */
const AFFECT_TABLE: Record<AvatarAffect, AffectParams> = {
// 중립/라포 — 세이지 4%, 안정 4s
neutral: {
eyelidDrop: 0,
gazeAvert: 0,
shoulderTurn: 0,
breathPeriod: 4,
breathAmp: 2,
auraHue: "#3E7A6E", // --accent (세이지-틸)
auraOpacity: 0.04,
blinkMin: 4,
blinkMax: 7,
mouthCurve: 1.2, // 미세 상향
browTilt: 0,
},
// 우울(저각성·부정) — 청회색 5%, 느리고 얕게 5.5s
depressed: {
eyelidDrop: 1.5,
gazeAvert: 1.5, // 시선 아래(주로 y, x는 미세)
shoulderTurn: 1,
breathPeriod: 5.5,
breathAmp: 1.5,
auraHue: "#5B6B73", // 청회색
auraOpacity: 0.05,
blinkMin: 5,
blinkMax: 8,
mouthCurve: -0.6, // 수평~미세 하향
browTilt: 1.2, // 안쪽 끝 살짝 처짐
},
// 불안(고각성·부정) — 차가운 청 5% 빠른 펄스, 깜빡임↑, 빠르고 얕게 3s
anxious: {
eyelidDrop: 0,
gazeAvert: 2,
shoulderTurn: 2,
breathPeriod: 3,
breathAmp: 1.5,
auraHue: "#3B6E8F", // 차가운 청
auraOpacity: 0.05,
blinkMin: 2,
blinkMax: 4,
mouthCurve: 0, // 다묾, 긴장
browTilt: 0.4,
},
// 저항/방어 — 중립 회색 무펄스, 반쯤 돌아선 8°, 정지에 가깝게
resistant: {
eyelidDrop: 0.5,
gazeAvert: 4, // 시선 회피(옆/아래 고정)
shoulderTurn: 8, // ★ 교육적 핵심: 라포로 0° 까지 완화
breathPeriod: 5,
breathAmp: 1,
auraHue: "#93A09C", // --ink-3 중립 회색
auraOpacity: 0.03,
blinkMin: 4,
blinkMax: 7,
mouthCurve: -0.2, // 굳게 닫힘
browTilt: -0.4,
},
};
/* ── 연령대 외형 규칙 (§4.6) ────────────────────────────────────────── */
export interface AgeBandLook {
/** 눈 크기 배율 */
eyeSize: number;
/** 턱 폭 배율 (작을수록 좁은 턱) */
jawWidth: number;
/** 광배 채도 가중(생기) */
auraSaturation: number;
}
const AGE_TABLE: Record<AvatarAgeBand, AgeBandLook> = {
teen: { eyeSize: 1.15, jawWidth: 0.92, auraSaturation: 1.1 },
youngAdult: { eyeSize: 1.0, jawWidth: 1.0, auraSaturation: 1.0 },
adult: { eyeSize: 0.95, jawWidth: 1.06, auraSaturation: 0.9 },
senior: { eyeSize: 0.92, jawWidth: 1.0, auraSaturation: 0.85 },
};
export function ageLookFor(ageBand: AvatarAgeBand): AgeBandLook {
return AGE_TABLE[ageBand];
}
/* ( + )
rapport 0~1: 세션 . .
"라포가 쌓이면 shoulderTurn 8°→0°, gazeAvert→눈맞춤" (§4.5). */
export function resolveAffectParams(
affect: AvatarAffect,
state: AvatarState,
rapport: number,
): AffectParams {
// 표를 복사(불변)
const out: AffectParams = { ...AFFECT_TABLE[affect] };
// 라포 완화 (0~1). 저항/회피 성격 파라미터를 비례 감쇠.
const r = Math.min(1, Math.max(0, rapport));
if (r > 0) {
out.shoulderTurn = out.shoulderTurn * (1 - r); // 8° → 0°
out.gazeAvert = out.gazeAvert * (1 - 0.85 * r); // 회피 → 눈맞춤
out.eyelidDrop = out.eyelidDrop * (1 - 0.5 * r);
// 라포가 쌓이면 광배가 중립 회색 → 세이지 쪽으로 살짝 온기
if (affect === "resistant" && r > 0.5) out.auraHue = "#6E8B82";
}
// 상태 보정 (§4.2)
switch (state) {
case "thinking":
// 시선 살짝 아래/옆, 깜빡임 느려짐, 광배 안쪽 수축(채도 -8%)
out.gazeAvert = Math.max(out.gazeAvert, 2);
out.blinkMin = out.blinkMin * 1.3;
out.blinkMax = out.blinkMax * 1.3;
out.auraOpacity *= 0.85;
out.breathAmp *= 0.6; // 정지에 가깝게
break;
case "listening":
// 학습자 쪽 응시(회피 해제), 차분 펄스
out.gazeAvert = out.gazeAvert * (1 - 0.6); // 응시로 당김(완전 0은 아님)
break;
case "speaking":
// 정면 복귀
out.gazeAvert = out.gazeAvert * 0.4;
break;
case "idle":
// 느린 호흡 3.5s 하한
out.breathPeriod = Math.max(out.breathPeriod, 3.5);
break;
}
return out;
}
/** persona.resistance → 초기 저항도. 미지정 시 affect 로 추정. */
export function baseResistanceOf(persona: AvatarPersona, affect: AvatarAffect): number {
if (typeof persona.resistance === "number") return persona.resistance;
return affect === "resistant" ? 0.6 : 0;
}

View file

@ -0,0 +1,223 @@
/* =====================================================================
useAvatarMotion requestAnimationFrame
출처: docs/DESIGN_CONCEPT.md §4.3() / §4.4( ) / §4.7(rAF 1)
채널: 어깨호흡(translateY) + (scaleY) + (RMSmouth scaleY) + saccade( )
(§4.4): 2. transform/opacity (layout ).
prefers-reduced-motion (ClientAvatar) .
코드: performance.now()/Math.random() OK ( ).
Rive 2
Rive .riv state-machine input(speak amplitude, blink,
gaze, breath) wiring . MotionFrame
ClientAvatar SVG .
===================================================================== */
import { useEffect, useRef, useState } from "react";
import type { AffectParams, AvatarState } from "./persona";
/** 매 프레임 컴포넌트로 내려가는 transform 값 묶음 */
export interface MotionFrame {
/** 어깨/몸 호흡 translateY px (음수=올라감) */
breath: number;
/** 깜빡임 눈 scaleY (1=뜸, 0.08=감김) */
blink: number;
/** 입 열림 0~1 (립싱크) */
mouth: number;
/** saccade 시선 x 미세 오프셋 px */
saccadeX: number;
/** saccade 시선 y 미세 오프셋 px */
saccadeY: number;
}
const IDLE_FRAME: MotionFrame = {
breath: 0,
blink: 1,
mouth: 0,
saccadeX: 0,
saccadeY: 0,
};
/* RMS ( τ180ms) + 0.04 (§4.3)
raw RMS . dt alpha . */
function smoothMouthFromRMS(
analyser: AnalyserNode,
buf: Float32Array<ArrayBuffer>,
prev: number,
dtSec: number,
): number {
analyser.getFloatTimeDomainData(buf);
let sum = 0;
for (let i = 0; i < buf.length; i++) sum += buf[i] * buf[i];
const rms = Math.sqrt(sum / buf.length);
// 데드존: 무음 구간 입 떨림 제거
const target = rms < 0.04 ? 0 : Math.min(1, rms * 3.2);
// τ≈180ms 지수평활 (dt 보정 → 프레임레이트 무관)
const tau = 0.18;
const alpha = 1 - Math.exp(-dtSec / tau);
return prev + (target - prev) * alpha;
}
/* analyser (§ : / )
speakingProgress(0~1, ) ,
RMS ( ). */
function fakeMouthTarget(tSec: number, progress: number | null): number {
if (progress !== null) {
// 타이핑 진행 중에만 입을 움직임. 진행이 멈추면(완료) 닫힘.
if (progress >= 1) return 0;
// 음절 리듬 ~6Hz, 미세 변주
const syl = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 5.5);
const jitter = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 11 + 1.3);
const base = 0.18 + 0.42 * syl * jitter;
return Math.min(0.85, base);
}
// progress 미제공: 차분한 의사 발화 (말하는 듯한 진폭)
const a = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 4.5);
const b = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 9.2 + 0.7);
return Math.min(0.8, 0.15 + 0.45 * a * b);
}
export interface AvatarMotionOptions {
state: AvatarState;
params: AffectParams;
analyser: AnalyserNode | null;
/** analyser 없을 때 가짜 립싱크용 타이핑 진행도 0~1 (null=의사 발화) */
speakingProgress?: number | null;
/** false 면 루프 정지(reduced-motion) — IDLE_FRAME 고정 */
enabled: boolean;
}
export function useAvatarMotion({
state,
params,
analyser,
speakingProgress = null,
enabled,
}: AvatarMotionOptions): MotionFrame {
const [frame, setFrame] = useState<MotionFrame>(IDLE_FRAME);
// 최신값을 rAF 클로저가 리렌더 없이 읽도록 ref 미러
const stateRef = useRef(state);
const paramsRef = useRef(params);
const analyserRef = useRef(analyser);
const progressRef = useRef(speakingProgress);
stateRef.current = state;
paramsRef.current = params;
analyserRef.current = analyser;
progressRef.current = speakingProgress;
useEffect(() => {
if (!enabled) {
setFrame(IDLE_FRAME);
return;
}
let raf = 0;
const t0 = performance.now();
let lastT = t0;
// 깜빡임 스케줄 (랜덤 간격, 120ms 감김)
const p0 = paramsRef.current;
let nextBlinkAt = 1200 + Math.random() * (p0.blinkMax - p0.blinkMin) * 1000;
let blinkUntil = 0;
// saccade 스케줄 (6~10s 간격, 0.3s 이동, ±3px)
let nextSaccadeAt = 4000 + Math.random() * 4000;
let saccadeStart = 0;
let saccadeTargetX = 0;
let saccadeTargetY = 0;
let saccadeFromX = 0;
let saccadeFromY = 0;
let saccadeX = 0;
let saccadeY = 0;
let mouthPrev = 0;
const audioBuf = new Float32Array(1024);
const loop = (t: number) => {
const p = paramsRef.current;
const st = stateRef.current;
const elapsed = t - t0;
const dtSec = Math.min(0.05, (t - lastT) / 1000); // 탭 비활성 복귀 시 점프 클램프
lastT = t;
const tSec = elapsed / 1000;
// ── 1) 어깨/몸 호흡 (translateY 1.5~2.5px, 3.5~5.5s) ──
const breath = Math.sin((tSec * 2 * Math.PI) / p.breathPeriod) * p.breathAmp;
// ── 2) 깜빡임 (scaleY 1→0.08→1, 120ms, 간격 4~7s 랜덤) ──
let blink = 1;
if (blinkUntil === 0 && elapsed >= nextBlinkAt) {
blinkUntil = elapsed + 120;
}
if (blinkUntil > 0) {
if (elapsed >= blinkUntil) {
blinkUntil = 0;
nextBlinkAt =
elapsed + p.blinkMin * 1000 + Math.random() * (p.blinkMax - p.blinkMin) * 1000;
blink = 1;
} else {
// 0→감김(0.08)→복귀 삼각 근사
const phase = 1 - Math.abs((blinkUntil - elapsed) / 120 - 0.5) * 2;
blink = 1 - phase * 0.92;
}
}
// ── 3) saccade (시선 미세 이동 ±3px, 0.3s, 간격 6~10s; thinking 빈도↑) ──
const saccadeInterval = st === "thinking" ? 3000 : 7000;
if (saccadeStart === 0 && elapsed >= nextSaccadeAt) {
saccadeStart = elapsed;
saccadeFromX = saccadeX;
saccadeFromY = saccadeY;
// listening 은 학습자 응시 → saccade 거의 없음(중앙 복귀)
const range = st === "listening" ? 1 : 3;
saccadeTargetX = (Math.random() * 2 - 1) * range;
saccadeTargetY = (Math.random() * 2 - 1) * (range * 0.6);
}
if (saccadeStart > 0) {
const k = Math.min(1, (elapsed - saccadeStart) / 300);
// ease-out
const e = 1 - Math.pow(1 - k, 2);
saccadeX = saccadeFromX + (saccadeTargetX - saccadeFromX) * e;
saccadeY = saccadeFromY + (saccadeTargetY - saccadeFromY) * e;
if (k >= 1) {
saccadeStart = 0;
nextSaccadeAt =
elapsed + saccadeInterval * 0.6 + Math.random() * saccadeInterval * 0.6;
}
}
// ── 4) 립싱크 (speaking 시만; analyser 우선, 없으면 폴백) ──
let mouth = mouthPrev;
if (st === "speaking") {
const a = analyserRef.current;
if (a) {
mouthPrev = smoothMouthFromRMS(a, audioBuf, mouthPrev, dtSec);
} else {
const target = fakeMouthTarget(tSec, progressRef.current ?? null);
const tau = 0.12;
const alpha = 1 - Math.exp(-dtSec / tau);
mouthPrev = mouthPrev + (target - mouthPrev) * alpha;
}
mouth = mouthPrev;
} else if (mouthPrev > 0.001) {
// 발화 종료 → 부드럽게 닫힘
const alpha = 1 - Math.exp(-dtSec / 0.1);
mouthPrev = mouthPrev * (1 - alpha);
if (mouthPrev < 0.005) mouthPrev = 0;
mouth = mouthPrev;
} else {
mouth = 0;
}
setFrame({ breath, blink, mouth, saccadeX, saccadeY });
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [enabled]);
return frame;
}

View file

@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import { Topbar } from "./Topbar";
import { Sidebar } from "./Sidebar";
import { useAuth } from "../../lib/auth";
export interface AppShellProps {
children: ReactNode;
/** 역할 컨텍스트 라벨 오버라이드 (없으면 user.role 라벨) */
contextLabel?: string;
/** 좌측 네비 숨김 (세션 화면처럼 집중 모드) */
hideNav?: boolean;
/** 메인 패딩·최대폭 제거 (풀-블리드 레이아웃) */
bleed?: boolean;
}
/**
* AppShell 3 . + ( ) + .
* body[data-role] AuthProvider ( ).
* ( ) (RequireAuth) .
*/
export function AppShell({ children, contextLabel, hideNav, bleed }: AppShellProps) {
const { user } = useAuth();
const showNav = !hideNav && !!user;
return (
<div className="vg-shell">
<Topbar contextLabel={contextLabel} />
<div className={"vg-shell__body" + (showNav ? "" : " vg-shell__body--bare")}>
{showNav ? <Sidebar role={user.role} /> : null}
<main className={"vg-main" + (bleed ? " vg-main--bleed" : "")}>
<div className="vg-main__inner">{children}</div>
</main>
</div>
</div>
);
}

View file

@ -0,0 +1,73 @@
import { NavLink } from "react-router-dom";
import { Icon, type IconName } from "../ui/Icon";
import type { Role } from "../../lib/auth";
export interface NavItem {
to: string;
label: string;
icon: IconName;
/** NavLink end (정확 매칭) */
end?: boolean;
}
/**
* . (DOM , §6.3).
* disabled .
*/
const NAV_BY_ROLE: Record<Role, NavItem[]> = {
learner: [
{ to: "/learn", label: "홈", icon: "home", end: true },
{ to: "/settings", label: "설정", icon: "settings" },
],
teacher: [
{ to: "/teach", label: "콘솔", icon: "users", end: true },
{ to: "/settings", label: "설정", icon: "settings" },
],
admin: [
{ to: "/admin", label: "운영", icon: "shield", end: true },
{ to: "/settings", label: "설정", icon: "settings" },
],
};
export function navItemsFor(role: Role): NavItem[] {
return NAV_BY_ROLE[role];
}
export interface SidebarProps {
role: Role;
/** 상단 그룹 라벨 (기본 "메뉴") */
groupLabel?: string;
}
/** 좌측 네비 (240px). 활성 = accent-tint 알약. 강조바 금지. §6.3 */
export function Sidebar({ role, groupLabel = "메뉴" }: SidebarProps) {
const items = navItemsFor(role);
return (
<nav className="vg-nav" aria-label="주 메뉴">
<span className="vg-nav__label">{groupLabel}</span>
{items.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
"vg-nav__item" + (isActive ? " is-active" : "")
}
>
<span className="vg-nav__ic">
<Icon name={item.icon} size={18} strokeWidth={1.75} />
</span>
<span>{item.label}</span>
</NavLink>
))}
<span className="vg-nav__spacer" />
<div className="vg-nav__foot">
<p className="vg-nav__ethic">
. · .
</p>
</div>
</nav>
);
}

View file

@ -0,0 +1,123 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Icon } from "../ui/Icon";
import { useAuth, roleLabel } from "../../lib/auth";
/** Vignette 워드마크 — 비네트(조리개) inline SVG. dev_dashboard 마크 계승. */
function BrandMark() {
return (
<svg viewBox="0 0 26 26" width={26} height={26} fill="none" aria-hidden="true">
<circle cx="13" cy="13" r="11" stroke="var(--accent)" strokeWidth="2" />
<path
d="M8 14.5c1.4 1.7 3 2.5 5 2.5s3.6-.8 5-2.5"
stroke="var(--accent)"
strokeWidth="2"
strokeLinecap="round"
/>
<circle cx="13" cy="9" r="1.6" fill="var(--clay)" />
</svg>
);
}
const THEME_KEY = "vignette.theme";
/** 라이트/다크 토글 (data-theme 반영). 토큰만으로 전환. */
function useTheme(): [boolean, () => void] {
const [dark, setDark] = useState<boolean>(() => {
try {
const saved = localStorage.getItem(THEME_KEY);
if (saved) return saved === "dark";
} catch {
/* 무시 */
}
return (
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-color-scheme: dark)").matches
);
});
useEffect(() => {
const root = document.documentElement;
if (dark) root.setAttribute("data-theme", "dark");
else root.removeAttribute("data-theme");
try {
localStorage.setItem(THEME_KEY, dark ? "dark" : "light");
} catch {
/* 무시 */
}
}, [dark]);
return [dark, () => setDark((d) => !d)];
}
function initials(name?: string | null): string {
const trimmed = (name ?? "").trim();
if (!trimmed) return "·";
// 한글이면 첫 글자, 영문이면 첫 글자 대문자
return trimmed.slice(0, 1);
}
export interface TopbarProps {
/** 역할 컨텍스트 라벨 오버라이드 (없으면 user.role 라벨) */
contextLabel?: string;
}
/** 공통 톱바 (56px). 브랜드 + 역할 라벨 + 테마/사용자/로그아웃. §6.3 */
export function Topbar({ contextLabel }: TopbarProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [dark, toggleTheme] = useTheme();
const label = contextLabel ?? (user ? roleLabel(user.role) : null);
const onLogout = async () => {
await logout();
navigate("/login", { replace: true });
};
return (
<header className="vg-topbar">
<Link className="vg-topbar__brand" to={user ? "/" : "/login"}>
<span className="vg-topbar__mark">
<BrandMark />
</span>
<span className="vg-topbar__wm">
<span className="v">Vignette</span>
</span>
</Link>
{label ? <span className="vg-topbar__role">{label}</span> : null}
<span className="vg-topbar__spacer" />
<div className="vg-topbar__actions">
<button
type="button"
className="vg-iconbtn"
onClick={toggleTheme}
aria-label={dark ? "라이트 모드로" : "다크 모드로"}
title={dark ? "라이트 모드" : "다크 모드"}
>
<Icon name={dark ? "sun" : "moon"} size={18} />
</button>
{user ? (
<>
<span className="vg-topbar__user">
<span className="vg-topbar__avatar" aria-hidden="true">
{initials(user.name)}
</span>
<span className="vg-topbar__uname">{user.name}</span>
</span>
<button
type="button"
className="vg-iconbtn"
onClick={onLogout}
aria-label="로그아웃"
title="로그아웃"
>
<Icon name="logout" size={18} />
</button>
</>
) : null}
</div>
</header>
);
}

View file

@ -0,0 +1,245 @@
/* =====================================================================
공통 §6.3
톱바 56px, 좌측 네비 240px. border-bottom 1px hair, 그림자 없음.
강조선 금지. 활성 네비 = 아이콘 accent + accent-tint 알약 배경(네비 알약만 8px 관용).
===================================================================== */
.vg-shell {
min-height: 100vh;
background: var(--bg-app);
}
/* ── 톱바 ── */
.vg-topbar {
height: var(--topbar-h);
background: var(--bg-surface);
border-bottom: 1px solid var(--hair);
display: flex;
align-items: center;
gap: var(--sp-5);
padding: 0 var(--sp-6);
position: sticky;
top: 0;
z-index: 30;
}
.vg-topbar__brand {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
}
.vg-topbar__brand:hover {
text-decoration: none;
}
.vg-topbar__mark {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
flex: none;
}
.vg-topbar__wm {
font-size: 17px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-strong);
}
.vg-topbar__wm .v {
color: var(--accent);
}
/* 역할 컨텍스트 라벨 — mono accent, 조용한 신호. 디바이더는 강조선 아님(셸 구획). */
.vg-topbar__role {
font-family: var(--font-num);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--accent);
padding-left: var(--sp-4);
margin-left: var(--sp-2);
border-left: 1px solid var(--hair);
}
.vg-topbar__spacer {
flex: 1;
}
.vg-topbar__actions {
display: flex;
align-items: center;
gap: var(--sp-2);
}
/* 톱바 아이콘 버튼 (테마/로그아웃) */
.vg-iconbtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: var(--radius);
border: 1px solid transparent;
background: transparent;
color: var(--text-body);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out);
}
.vg-iconbtn:hover {
background: var(--bg-surface-2);
color: var(--text-strong);
}
/* 사용자 칩 */
.vg-topbar__user {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 5px 11px 5px 6px;
border-radius: 100px;
background: var(--bg-surface-2);
}
.vg-topbar__avatar {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--accent-tint);
color: var(--accent-deep);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
flex: none;
}
.vg-topbar__uname {
font-size: 13px;
font-weight: 600;
color: var(--text-strong);
}
/* ── 본문 그리드: 좌측 네비 + 메인 ── */
.vg-shell__body {
display: grid;
grid-template-columns: var(--nav-w) 1fr;
align-items: start;
}
.vg-shell__body--bare {
grid-template-columns: 1fr;
}
/* ── 좌측 네비 ── */
.vg-nav {
position: sticky;
top: var(--topbar-h);
align-self: start;
height: calc(100vh - var(--topbar-h));
background: var(--bg-surface);
border-right: 1px solid var(--hair);
padding: var(--sp-5) var(--sp-3);
display: flex;
flex-direction: column;
gap: var(--sp-1);
overflow-y: auto;
}
.vg-nav__label {
font-family: var(--font-num);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
padding: var(--sp-2) var(--sp-3) 6px;
}
.vg-nav__item {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 12px;
border-radius: var(--radius); /* 네비 알약만 8px 관용 §6.3 */
font-size: 14px;
font-weight: 500;
color: var(--text-body);
text-decoration: none;
transition:
background var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out);
}
.vg-nav__item:hover {
background: var(--bg-surface-2);
color: var(--text-strong);
text-decoration: none;
}
.vg-nav__item .vg-nav__ic {
flex: none;
color: var(--text-muted);
display: inline-flex;
}
/* 활성: 아이콘 accent + accent-tint 알약 배경. 3px 강조바 금지. */
.vg-nav__item.is-active {
background: var(--accent-tint);
color: var(--accent-deep);
font-weight: 600;
}
.vg-nav__item.is-active .vg-nav__ic {
color: var(--accent);
}
.vg-nav__spacer {
flex: 1;
}
.vg-nav__foot {
padding: var(--sp-3) var(--sp-3) 0;
border-top: 1px solid var(--hair);
margin-top: var(--sp-3);
}
.vg-nav__ethic {
font-size: 11.5px;
line-height: 1.55;
color: var(--text-muted);
}
/* ── 메인 콘텐츠 ── */
.vg-main {
min-width: 0; /* 그리드 자식 overflow 방지 */
padding: var(--sp-7) var(--sp-6) var(--sp-8);
}
.vg-main__inner {
max-width: var(--maxw);
margin: 0 auto;
}
/* 풀-블리드(세션 화면 등): 패딩/최대폭 없이 셸만 */
.vg-main--bleed {
padding: 0;
}
.vg-main--bleed .vg-main__inner {
max-width: none;
}
/* ── 반응형 ── */
@media (max-width: 1024px) {
.vg-shell__body {
grid-template-columns: var(--nav-w-collapsed) 1fr;
}
.vg-nav {
padding: var(--sp-4) var(--sp-2);
}
.vg-nav__label,
.vg-nav__item span:not(.vg-nav__ic),
.vg-nav__ethic {
display: none;
}
.vg-nav__item {
justify-content: center;
padding: 11px;
}
}
@media (max-width: 720px) {
.vg-topbar__role {
display: none;
}
.vg-topbar__uname {
display: none;
}
.vg-main {
padding: var(--sp-5) var(--sp-4) var(--sp-7);
}
}

View file

@ -0,0 +1,20 @@
import type { HTMLAttributes, ReactNode } from "react";
export type BadgeTone = "neutral" | "accent" | "pos" | "warn" | "crit" | "info";
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
tone?: BadgeTone;
children: ReactNode;
}
/** Badge — 배경 틴트 + 텍스트. 좌측 컬러바 금지. §7.4 톤 매핑과 일치. */
export function Badge({ tone = "neutral", className, children, ...rest }: BadgeProps) {
const cls = ["vg-badge", `vg-badge--${tone}`, className ?? ""]
.filter(Boolean)
.join(" ");
return (
<span className={cls} {...rest}>
{children}
</span>
);
}

View file

@ -0,0 +1,46 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export type ButtonSize = "sm" | "md" | "lg";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
/** 가로 100% */
block?: boolean;
/** 좌측 아이콘(보통 <Icon/>) */
leading?: ReactNode;
/** 우측 아이콘 */
trailing?: ReactNode;
}
/** 위계 3단(primary/secondary/ghost) + danger. radius 8px, 알약 금지. §7.1 */
export function Button({
variant = "primary",
size = "md",
block = false,
leading,
trailing,
className,
children,
type = "button",
...rest
}: ButtonProps) {
const cls = [
"vg-btn",
`vg-btn--${variant}`,
size !== "md" ? `vg-btn--${size}` : "",
block ? "vg-btn--block" : "",
className ?? "",
]
.filter(Boolean)
.join(" ");
return (
<button type={type} className={cls} {...rest}>
{leading}
{children}
{trailing}
</button>
);
}

View file

@ -0,0 +1,26 @@
import type { HTMLAttributes, ReactNode } from "react";
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
/** 그림자 제거(헤어라인만) */
flat?: boolean;
/** accent-tint 배경 강조 패널 (좌측바 대신 틴트) */
tint?: boolean;
children?: ReactNode;
}
/** 카드: 헤어라인 + 미세 그림자. 상단 강조선 금지. 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}>
{children}
</div>
);
}

View file

@ -0,0 +1,36 @@
import type { HTMLAttributes } from "react";
export type DotTone =
| "neutral"
| "accent"
| "pos"
| "warn"
| "crit"
| "info"
| "muted";
export interface DotProps extends HTMLAttributes<HTMLSpanElement> {
tone?: DotTone;
/** px 크기 (기본 8) */
size?: number;
}
/** Dot — 상태 점. 원형 예외 허용 대상(아바타/오브/dot). */
export function Dot({ tone = "neutral", size, className, style, ...rest }: DotProps) {
const cls = [
"vg-dot",
tone !== "neutral" ? `vg-dot--${tone}` : "",
className ?? "",
]
.filter(Boolean)
.join(" ");
const sz = size != null ? { width: size, height: size } : null;
return (
<span
className={cls}
aria-hidden="true"
style={sz ? { ...sz, ...style } : style}
{...rest}
/>
);
}

View file

@ -0,0 +1,57 @@
import { useId } from "react";
import type { InputHTMLAttributes, ReactNode } from "react";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
invalid?: boolean;
}
/** Input — radius 6px, 포커스 보더색 + ring. 좌측바 금지. §7.2 */
export function Input({ invalid, className, ...rest }: InputProps) {
const cls = ["vg-input", className ?? ""].filter(Boolean).join(" ");
return (
<input
className={cls}
aria-invalid={invalid ? "true" : undefined}
{...rest}
/>
);
}
export interface FieldProps {
/** 라벨 텍스트 */
label?: ReactNode;
/** 보조 안내 */
hint?: ReactNode;
/** 에러 메시지 (있으면 input invalid) */
error?: ReactNode;
/** input 에 연결할 자식. id 는 자동 연결되지 않으므로 필요 시 htmlFor 직접 사용 */
children: ReactNode;
/** label htmlFor 연결용 (없으면 useId) */
htmlFor?: string;
className?: string;
}
/**
* Field + + / .
* children <Input/> ( ).
*/
export function Field({ label, hint, error, children, htmlFor, className }: FieldProps) {
const autoId = useId();
const fieldId = htmlFor ?? autoId;
const cls = ["vg-field", className ?? ""].filter(Boolean).join(" ");
return (
<div className={cls}>
{label ? (
<label className="vg-field__label" htmlFor={fieldId}>
{label}
</label>
) : null}
{children}
{error ? (
<span className="vg-field__error">{error}</span>
) : hint ? (
<span className="vg-field__hint">{hint}</span>
) : null}
</div>
);
}

View file

@ -0,0 +1,216 @@
/* =====================================================================
Icon inline SVG (stroke , currentColor)
철칙: 이모지 . stroke=currentColor, fill=none.
(currentColor) .
Features name ( ).
===================================================================== */
import type { ReactNode, SVGProps } from "react";
export type IconName =
| "home"
| "session"
| "review"
| "users"
| "settings"
| "shield"
| "logout"
| "chevron-right"
| "chevron-left"
| "check"
| "mic"
| "mic-off"
| "pause"
| "play"
| "x"
| "info"
| "alert"
| "arrow-up"
| "arrow-down"
| "arrow-flat"
| "dot"
| "menu"
| "google"
| "school"
| "sun"
| "moon";
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
name: IconName;
/** px 크기 (정사각). 기본 18 */
size?: number;
/** stroke 두께. 기본 1.75 (Lucide 톤) */
strokeWidth?: number;
/** 접근성 라벨. 없으면 aria-hidden */
title?: string;
}
// 각 아이콘의 path/children (24x24 viewBox 기준). google 만 멀티컬러 예외.
const PATHS: Record<IconName, ReactNode> = {
home: (
<>
<path d="M3 9.5 12 4l9 5.5" />
<path d="M5 11v8h14v-8" />
<path d="M9 19v-5h6v5" />
</>
),
session: (
<>
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</>
),
review: (
<>
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z" />
<path d="M9 13h6M9 17h4" />
</>
),
users: (
<>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
</>
),
settings: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</>
),
shield: (
<>
<path d="M12 3 5 6v5c0 4.4 3 8.5 7 9.7 4-1.2 7-5.3 7-9.7V6l-7-3z" />
</>
),
logout: (
<>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5M21 12H9" />
</>
),
"chevron-right": <polyline points="9 6 15 12 9 18" />,
"chevron-left": <polyline points="15 6 9 12 15 18" />,
check: <polyline points="20 6 9 17 4 12" />,
mic: (
<>
<path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" />
<path d="M19 10v1a7 7 0 0 1-14 0v-1M12 18v4M8 22h8" />
</>
),
"mic-off": (
<>
<path d="M9 9v2a3 3 0 0 0 5.12 2.12M15 9.34V5a3 3 0 0 0-5.94-.6" />
<path d="M19 10v1a7 7 0 0 1-.11 1.23M12 18v4M8 22h8M5 10v1a7 7 0 0 0 1.94 4.83" />
<line x1="3" y1="3" x2="21" y2="21" />
</>
),
pause: (
<>
<line x1="9" y1="5" x2="9" y2="19" />
<line x1="15" y1="5" x2="15" y2="19" />
</>
),
play: <polygon points="7 4 19 12 7 20 7 4" />,
x: (
<>
<line x1="6" y1="6" x2="18" y2="18" />
<line x1="18" y1="6" x2="6" y2="18" />
</>
),
info: (
<>
<circle cx="12" cy="12" r="9" />
<line x1="12" y1="11" x2="12" y2="16" />
<line x1="12" y1="8" x2="12" y2="8" />
</>
),
alert: (
<>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12" y2="17" />
</>
),
"arrow-up": <polyline points="6 14 12 8 18 14" />,
"arrow-down": <polyline points="6 10 12 16 18 10" />,
"arrow-flat": <line x1="5" y1="12" x2="19" y2="12" />,
dot: <circle cx="12" cy="12" r="4" />,
menu: (
<>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</>
),
school: (
<>
<path d="M3 9.5 12 4l9 5.5" />
<path d="M5 11v7h14v-7" />
<path d="M9 18v-4h6v4" />
</>
),
// google 은 브랜드 멀티컬러 → fill 기반 예외(아이콘 가이드라인의 외부 OAuth 예외)
google: (
<>
<path
fill="#4285F4"
stroke="none"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.76h3.56c2.08-1.92 3.28-4.74 3.28-8.09Z"
/>
<path
fill="#34A853"
stroke="none"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.56-2.76c-.98.66-2.24 1.06-3.72 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84A11 11 0 0 0 12 23Z"
/>
<path
fill="#FBBC05"
stroke="none"
d="M5.84 14.11a6.6 6.6 0 0 1 0-4.22V7.05H2.18a11 11 0 0 0 0 9.9l3.66-2.84Z"
/>
<path
fill="#EA4335"
stroke="none"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1A11 11 0 0 0 2.18 7.05l3.66 2.84C6.71 7.29 9.14 5.38 12 5.38Z"
/>
</>
),
sun: (
<>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</>
),
moon: <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />,
};
export function Icon({
name,
size = 18,
strokeWidth = 1.75,
title,
...rest
}: IconProps) {
const isBrand = name === "google";
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={isBrand ? "none" : "currentColor"}
strokeWidth={isBrand ? undefined : strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
role={title ? "img" : undefined}
aria-label={title}
aria-hidden={title ? undefined : true}
focusable="false"
{...rest}
>
{title ? <title>{title}</title> : null}
{PATHS[name]}
</svg>
);
}

View file

@ -0,0 +1,18 @@
import type { HTMLAttributes, ReactNode } from "react";
export interface KickerProps extends HTMLAttributes<HTMLSpanElement> {
/** 좌측 accent dot 표시 (기본 true) */
dot?: boolean;
children: ReactNode;
}
/** Kicker — 상단 소제목. 양산형 좌측바를 대체하는 위계 신호. §3.6 */
export function Kicker({ dot = true, className, children, ...rest }: KickerProps) {
const cls = ["vg-kicker", className ?? ""].filter(Boolean).join(" ");
return (
<span className={cls} {...rest}>
{dot ? <span className="vg-kicker__dot" aria-hidden="true" /> : null}
{children}
</span>
);
}

View file

@ -0,0 +1,26 @@
import type { HTMLAttributes, ReactNode } from "react";
export interface PanelProps extends HTMLAttributes<HTMLDivElement> {
/** 그림자 제거 */
flat?: boolean;
/** accent-tint 강조 패널 */
tint?: boolean;
children?: ReactNode;
}
/** 패널: 카드보다 큰 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}>
{children}
</div>
);
}

View file

@ -0,0 +1,50 @@
import { clamp01 } from "../../lib/format";
export type ProgressTone = "accent" | "muted" | "warn" | "clay";
export interface ProgressBarProps {
/** 0~1 비율 (또는 value/max) */
value: number;
/** value 의 최대치 (기본 1 → value 를 비율로 간주) */
max?: number;
tone?: ProgressTone;
/** 얇은 4px 게이지 (상태 미터용) */
slim?: boolean;
/** 접근성 라벨 */
label?: string;
className?: string;
}
/** ProgressBar — 가로 게이지. 라운드 없는 사각 트랙, 천천히 채움. §5.5/§6.4 */
export function ProgressBar({
value,
max = 1,
tone = "accent",
slim = false,
label,
className,
}: ProgressBarProps) {
const ratio = clamp01(max === 0 ? 0 : value / max);
const pct = Math.round(ratio * 100);
const trackCls = ["vg-progress", slim ? "vg-progress--slim" : "", className ?? ""]
.filter(Boolean)
.join(" ");
const fillCls = [
"vg-progress__fill",
tone !== "accent" ? `vg-progress__fill--${tone}` : "",
]
.filter(Boolean)
.join(" ");
return (
<div
className={trackCls}
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
>
<span className={fillCls} style={{ width: `${pct}%` }} />
</div>
);
}

View file

@ -0,0 +1,51 @@
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

@ -0,0 +1,36 @@
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,38 @@
/* =====================================================================
UI Features import .
: import { Button, Card, Kicker, Icon } from "../components/ui";
main.tsx ui.css 1 import ( ).
===================================================================== */
export { Button } from "./Button";
export type { ButtonProps, ButtonVariant, ButtonSize } from "./Button";
export { Card } from "./Card";
export type { CardProps } from "./Card";
export { Panel } from "./Panel";
export type { PanelProps } from "./Panel";
export { Kicker } from "./Kicker";
export type { KickerProps } from "./Kicker";
export { SectionHead } from "./SectionHead";
export type { SectionHeadProps } from "./SectionHead";
export { Badge } from "./Badge";
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";
export { Field, Input } from "./Field";
export type { FieldProps, InputProps } from "./Field";
export { Icon } from "./Icon";
export type { IconProps, IconName } from "./Icon";

View file

@ -0,0 +1,332 @@
/* =====================================================================
UI 프리미티브 스타일 토큰 변수만 참조(tokens.css).
철칙: border-left 강조선 0 · 카드 상단 강조선 0 · 알약 radius 금지(원형은 dot/오브/아바타만).
강조 = weight + accent 텍스트 + accent-tint 배경 + kicker(+dot).
===================================================================== */
/* ── Kicker (상단 소제목, 양산형 좌측바 대체 위계 신호) §3.6 ── */
.vg-kicker {
font-family: var(--font-num);
font-size: var(--fs-kicker);
font-weight: 600;
letter-spacing: 0.08em;
color: var(--accent);
display: inline-flex;
align-items: center;
gap: 8px;
text-transform: none;
}
.vg-kicker__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-bright);
flex: none;
}
/* ── SectionHead ── */
.vg-sechead {
margin-bottom: var(--sp-5);
}
.vg-sechead__kicker {
margin-bottom: 6px;
}
.vg-sechead__title {
font-size: var(--fs-h2);
font-weight: 600;
letter-spacing: -0.015em;
color: var(--text-strong);
line-height: 1.35;
}
.vg-sechead__desc {
font-size: var(--fs-xs);
color: var(--text-muted);
margin-top: 4px;
}
/* ── Button (위계 3단 + danger). radius 8px, 알약 금지 §7.1 ── */
.vg-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font: 600 var(--fs-sm) / 1 var(--font-sans);
padding: 10px 18px;
border-radius: var(--radius);
border: 1px solid transparent;
transition:
background var(--dur-base) var(--ease-out),
border-color var(--dur-base) var(--ease-out),
transform var(--dur-fast) var(--ease-out);
cursor: pointer;
white-space: nowrap;
user-select: none;
}
.vg-btn:active {
transform: translateY(0.5px);
}
.vg-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.vg-btn--block {
width: 100%;
}
.vg-btn--sm {
padding: 7px 13px;
font-size: var(--fs-xs);
}
.vg-btn--lg {
padding: 13px 22px;
font-size: var(--fs-body);
}
.vg-btn--primary {
background: var(--accent);
color: var(--text-on-accent);
}
.vg-btn--primary:hover:not(:disabled) {
background: var(--accent-deep);
}
.vg-btn--secondary {
background: var(--bg-surface);
color: var(--accent);
border-color: var(--accent);
}
.vg-btn--secondary:hover:not(:disabled) {
background: var(--accent-tint);
}
.vg-btn--ghost {
background: transparent;
color: var(--text-body);
}
.vg-btn--ghost:hover:not(:disabled) {
background: var(--bg-surface-2);
color: var(--text-strong);
}
.vg-btn--danger {
background: var(--crit-solid);
color: #fff;
}
.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);
border-radius: var(--radius);
padding: var(--sp-5);
box-shadow: var(--shadow-sm);
}
.vg-card--flat {
box-shadow: none;
}
.vg-card--tint {
background: var(--bg-tint);
border-color: transparent;
box-shadow: none;
}
.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 (배경 틴트 + 텍스트, 좌측바 없음) ── */
.vg-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-num);
font-size: var(--fs-xs);
font-weight: 600;
letter-spacing: 0.01em;
padding: 2px 9px;
border-radius: 100px; /* 칩/배지만 100px 관용 (DESIGN_CONCEPT 데모 일치) */
line-height: 1.5;
}
.vg-badge--neutral {
color: var(--ink-2);
background: var(--paper-2);
}
.vg-badge--accent {
color: var(--accent-deep);
background: var(--accent-tint);
}
.vg-badge--pos {
color: var(--pos-text);
background: var(--pos-tint);
}
.vg-badge--warn {
color: var(--warn-text);
background: var(--warn-tint);
}
.vg-badge--crit {
color: var(--crit-text);
background: var(--crit-tint);
}
.vg-badge--info {
color: var(--info-text);
background: var(--info-tint);
}
/* ── Dot (상태 점). 원형 예외 허용 대상 ── */
.vg-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex: none;
background: var(--neutral-200);
}
.vg-dot--accent {
background: var(--accent);
}
.vg-dot--pos {
background: var(--pos-solid);
}
.vg-dot--warn {
background: var(--warn-solid);
}
.vg-dot--crit {
background: var(--crit-solid);
}
.vg-dot--info {
background: var(--info-solid);
}
.vg-dot--muted {
background: var(--ink-3);
}
/* ── StatLine (요약 스탯, tabular) ── */
.vg-statline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-5);
}
.vg-statline__item {
display: inline-flex;
align-items: baseline;
gap: 7px;
}
.vg-statline__num {
font-size: 20px;
font-weight: 700;
color: var(--text-strong);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.vg-statline__lab {
font-size: var(--fs-xs);
color: var(--text-muted);
}
.vg-statline__sep {
width: 1px;
height: 18px;
background: var(--hair);
}
/* ── ProgressBar (가로 게이지, 라운드 없는 사각 트랙) §5.5/§6.4 ── */
.vg-progress {
width: 100%;
height: 8px;
background: var(--paper-2);
border-radius: 4px;
overflow: hidden;
}
.vg-progress--slim {
height: 4px;
}
.vg-progress__fill {
height: 100%;
border-radius: 4px;
background: var(--accent);
transition: width var(--dur-slow) var(--ease-out);
}
.vg-progress__fill--muted {
background: var(--neutral-200);
}
.vg-progress__fill--warn {
background: var(--warn-solid);
}
.vg-progress__fill--clay {
background: var(--clay);
}
/* ── Field / Input §7.2 (radius 6px, 포커스 보더색+ring, 좌측바 X) ── */
.vg-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.vg-field__label {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-strong);
}
.vg-field__hint {
font-size: var(--fs-xs);
color: var(--text-muted);
}
.vg-field__error {
font-size: var(--fs-xs);
color: var(--crit-text);
}
.vg-input {
width: 100%;
background: var(--bg-surface-2);
color: var(--text-strong);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding: 10px 12px;
font: var(--fs-body) / 1.5 var(--font-sans);
transition:
border-color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
}
.vg-input::placeholder {
color: var(--neutral-400);
}
.vg-input:focus {
border-color: var(--border-focus);
outline: none;
box-shadow: 0 0 0 3px var(--focus-ring);
}
.vg-input[aria-invalid="true"] {
border-color: var(--crit-solid);
}
/* ── 빈/준비중 상태 (스텁 페이지 공통) ── */
.vg-empty {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--sp-3);
max-width: var(--maxw-read);
}
.vg-empty__title {
font-size: var(--fs-h1);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-strong);
line-height: 1.3;
}
.vg-empty__desc {
font-size: var(--fs-lead);
color: var(--text-body);
line-height: 1.6;
}