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:
parent
859ab26314
commit
24b1b7a6e1
84 changed files with 19645 additions and 107 deletions
45
apps/web/src/components/avatar/AuraLayer.tsx
Normal file
45
apps/web/src/components/avatar/AuraLayer.tsx
Normal 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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
35
apps/web/src/components/avatar/BodySilhouette.tsx
Normal file
35
apps/web/src/components/avatar/BodySilhouette.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
43
apps/web/src/components/avatar/Brows.tsx
Normal file
43
apps/web/src/components/avatar/Brows.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
248
apps/web/src/components/avatar/ClientAvatar.tsx
Normal file
248
apps/web/src/components/avatar/ClientAvatar.tsx
Normal 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;}
|
||||
}
|
||||
`;
|
||||
48
apps/web/src/components/avatar/Eyes.tsx
Normal file
48
apps/web/src/components/avatar/Eyes.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/* =====================================================================
|
||||
Eyes — 눈 (깜빡임 scaleY + 시선 gaze + 눈꺼풀 하강)
|
||||
§4.1: 흰자 없이 단순 아몬드/점. 코·주름·모공 없음(35% 사실성).
|
||||
§4.4: 깜빡임 scaleY 1→0.08→1. §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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/components/avatar/Mouth.tsx
Normal file
51
apps/web/src/components/avatar/Mouth.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/* =====================================================================
|
||||
Mouth — 입 (RMS→scaleY 립싱크 + 입꼬리 정서)
|
||||
§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>
|
||||
);
|
||||
}
|
||||
202
apps/web/src/components/avatar/persona.ts
Normal file
202
apps/web/src/components/avatar/persona.ts
Normal 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;
|
||||
}
|
||||
223
apps/web/src/components/avatar/useAvatarMotion.ts
Normal file
223
apps/web/src/components/avatar/useAvatarMotion.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/* =====================================================================
|
||||
useAvatarMotion — 단일 requestAnimationFrame 모션 루프
|
||||
출처: docs/DESIGN_CONCEPT.md §4.3(립싱크) / §4.4(모션 진폭) / §4.7(rAF 1루프)
|
||||
|
||||
채널: 어깨호흡(translateY) + 깜빡임(scaleY) + 립싱크(RMS→mouth 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;
|
||||
}
|
||||
36
apps/web/src/components/shell/AppShell.tsx
Normal file
36
apps/web/src/components/shell/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
apps/web/src/components/shell/Sidebar.tsx
Normal file
73
apps/web/src/components/shell/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
apps/web/src/components/shell/Topbar.tsx
Normal file
123
apps/web/src/components/shell/Topbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
245
apps/web/src/components/shell/shell.css
Normal file
245
apps/web/src/components/shell/shell.css
Normal 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);
|
||||
}
|
||||
}
|
||||
20
apps/web/src/components/ui/Badge.tsx
Normal file
20
apps/web/src/components/ui/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/web/src/components/ui/Button.tsx
Normal file
46
apps/web/src/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
apps/web/src/components/ui/Card.tsx
Normal file
26
apps/web/src/components/ui/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/components/ui/Dot.tsx
Normal file
36
apps/web/src/components/ui/Dot.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
apps/web/src/components/ui/Field.tsx
Normal file
57
apps/web/src/components/ui/Field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
apps/web/src/components/ui/Icon.tsx
Normal file
216
apps/web/src/components/ui/Icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/web/src/components/ui/Kicker.tsx
Normal file
18
apps/web/src/components/ui/Kicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
apps/web/src/components/ui/Panel.tsx
Normal file
26
apps/web/src/components/ui/Panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
apps/web/src/components/ui/ProgressBar.tsx
Normal file
50
apps/web/src/components/ui/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/components/ui/SectionHead.tsx
Normal file
51
apps/web/src/components/ui/SectionHead.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/components/ui/StatLine.tsx
Normal file
36
apps/web/src/components/ui/StatLine.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
apps/web/src/components/ui/index.ts
Normal file
38
apps/web/src/components/ui/index.ts
Normal 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";
|
||||
332
apps/web/src/components/ui/ui.css
Normal file
332
apps/web/src/components/ui/ui.css
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue