대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정

SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리

페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침

버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)

검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
Yun Chan 2026-06-27 02:30:46 +09:00
parent cb2aebd76c
commit 085460b5e0
327 changed files with 31226 additions and 1829 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,164 @@
import { AppShell } from "../components/shell/AppShell";
import { Kicker } from "../components/ui";
import {
AVATAR_EXPRESSION_LIBRARY,
ClientAvatar,
type AvatarExpression,
type AvatarPersona,
} from "../components/avatar/ClientAvatar";
import {
PERSONA_LIVE2D_MODELS,
live2dModel3Path,
live2dMotionForExpression,
type Live2DPersonaCode,
type Live2DPersonaModel,
} from "../components/avatar/live2dModel";
import "./avatar-expression-lab.css";
const PERSONA_CODES = ["P4", "P5", "P6", "P7"] as const satisfies readonly Live2DPersonaCode[];
function avatarFromModel(model: Live2DPersonaModel): AvatarPersona {
return {
code: model.personaCode,
label: model.displayName,
ageBand: "teen",
skinTone: model.art.skinTone,
hair: { style: model.art.hairStyle, color: model.art.hairColor },
outfitColor: model.art.outfitColor,
eyeColor: "#2B2B2B",
accentColor: model.art.accentColor,
realism: 0.4,
resistance: model.personaCode === "P7" ? 0.68 : model.personaCode === "P4" ? 0.3 : 0.45,
expressionBias: model.defaultExpression,
};
}
function expressionTone(expression: AvatarExpression): string {
return AVATAR_EXPRESSION_LIBRARY.find((item) => item.id === expression)?.group ?? "cognitive";
}
export default function AvatarExpressionLab() {
const models = PERSONA_CODES.map((code) => PERSONA_LIVE2D_MODELS[code]);
const expressionCount = AVATAR_EXPRESSION_LIBRARY.length;
const renderedAvatarCount = models.length * expressionCount;
return (
<AppShell contextLabel="Live2D QA">
<div
className="axl"
data-avatar-expression-lab="true"
data-persona-count={models.length}
data-expression-count={expressionCount}
data-rendered-avatar-count={renderedAvatarCount}
>
<header className="axl__head">
<Kicker dot>Live2D persona rig</Kicker>
<div>
<h1>Persona expression board</h1>
<p>
P4-P7 first-party avatar models render every expression motion from the shared
library.
</p>
</div>
<div className="axl__stats" aria-label="Live2D expression coverage">
<span>
<b>{models.length}</b>
personas
</span>
<span>
<b>{expressionCount}</b>
expressions
</span>
<span>
<b>{renderedAvatarCount}</b>
previews
</span>
</div>
</header>
<div className="axl__stack">
{models.map((model) => {
const persona = avatarFromModel(model);
const modelPath = live2dModel3Path(model);
return (
<section
className="axl__persona"
data-persona-expression-panel="true"
data-persona-code={model.personaCode}
data-live2d-model={model.modelId}
data-live2d-model-url={modelPath}
data-expression-count={model.expressions.length}
key={model.personaCode}
>
<div className="axl__persona-head">
<ClientAvatar
persona={persona}
state="idle"
affect={model.defaultExpression}
size={132}
animated={false}
/>
<div className="axl__persona-title">
<Kicker dot={false}>{model.personaCode}</Kicker>
<h2>{model.displayName}</h2>
<dl>
<div>
<dt>model</dt>
<dd>{model.modelId}</dd>
</div>
<div>
<dt>model3</dt>
<dd>{modelPath}</dd>
</div>
<div>
<dt>default</dt>
<dd>{model.defaultExpression}</dd>
</div>
</dl>
</div>
</div>
<div className="axl__grid" role="list" aria-label={`${model.personaCode} expressions`}>
{AVATAR_EXPRESSION_LIBRARY.map((expression) => {
const motion = live2dMotionForExpression(model, expression.id);
const tone = expressionTone(expression.id);
return (
<article
className={`axl__card axl__card--${tone}`}
data-expression-card="true"
data-expression={expression.id}
data-expression-group={tone}
data-motion-file={motion.file}
data-fade-in-ms={motion.fadeInMs}
data-duration-ms={motion.durationMs}
key={expression.id}
role="listitem"
>
<ClientAvatar
persona={persona}
state="idle"
affect={expression.id}
size={104}
animated={false}
showCaption={false}
showMeta={false}
/>
<div className="axl__card-meta">
<b>{expression.label}</b>
<span>{expression.id}</span>
<small>{motion.file}</small>
</div>
</article>
);
})}
</div>
</section>
);
})}
</div>
</div>
</AppShell>
);
}

View file

@ -0,0 +1,74 @@
/* =====================================================================
AvatarPreview dev ( ).
/dev/avatar-preview. · .
===================================================================== */
import { ClientAvatar, type AvatarExpression, type AvatarPersona } from "../components/avatar/ClientAvatar";
import { AVATAR_EXPRESSION_LIBRARY } from "../components/avatar/ClientAvatar";
import "./avatar-preview.css";
const SEOYEON: AvatarPersona = {
code: "P1",
label: "서연(가명) · 고2 · 우울/자살사고",
ageBand: "teen",
skinTone: "#F0DDC4",
hair: { style: "long-straight", color: "#5A4030" },
outfitColor: "#7C8A92",
eyeColor: "#7A5A3C",
accentColor: "#6B5E7D",
rasterArtSet: "seoyeon",
realism: 0.4,
expressionBias: "sad",
};
// 대표 표정만 노출(28개 전체는 AvatarExpressionLab 참조)
const PREVIEW_EXPRS: AvatarExpression[] = [
"neutral",
"calm",
"warm",
"joy",
"sad",
"grief",
"anxious",
"tired",
"confused",
"startled",
"guarded",
"determined",
];
export default function AvatarPreview() {
return (
<div className="ap" data-avatar-preview="true">
<header className="ap__head">
<h1> (raster / Live2D식)</h1>
<p>imagegen gpt-image-2 + BiRefNet . ·· .</p>
</header>
<section className="ap__hero">
<ClientAvatar persona={SEOYEON} state="speaking" affect="sad" size={220} />
<ClientAvatar persona={SEOYEON} state="idle" affect="sad" size={220} />
<ClientAvatar persona={SEOYEON} state="listening" affect="warm" size={220} />
<ClientAvatar persona={SEOYEON} state="thinking" affect="anxious" size={220} />
</section>
<section className="ap__grid">
{PREVIEW_EXPRS.map((expr) => (
<figure className="ap__cell" key={expr}>
<ClientAvatar
persona={SEOYEON}
state="idle"
affect={expr}
size={150}
animated={false}
showMeta={false}
/>
<figcaption>{expr}</figcaption>
</figure>
))}
</section>
<p className="ap__count"> : {AVATAR_EXPRESSION_LIBRARY.length}</p>
</div>
);
}

View file

@ -209,7 +209,15 @@ export default function LearnerHome() {
<div className="lh-personas" role="listbox" aria-label="연습 페르소나">
{loadState === "loading"
? Array.from({ length: 3 }).map((_, i) => (
<div className="lh-persona is-skel" key={i} aria-hidden="true" />
<div className="lh-persona is-skel" key={i} aria-hidden="true">
<span className="lh-persona__mark lh-skel__box" />
<span className="lh-persona__body">
<span className="lh-skel__line lh-skel__line--name" />
<span className="lh-skel__line lh-skel__line--meta" />
<span className="lh-skel__line lh-skel__line--sum" />
<span className="lh-skel__line lh-skel__line--sum2" />
</span>
</div>
))
: personas.map((persona) => {
const usablePersona = isUsablePersona(persona);
@ -257,24 +265,40 @@ export default function LearnerHome() {
<section className="lh-preview" aria-labelledby="lh-preview-title">
<div className="lh-preview__main">
<div className="lh-preview__identity">
<div className="lh-avatar" aria-hidden="true">
{personaCatalogUnavailable ? "!" : personaInitial(selected)}
<div className="lh-preview__hero">
<div className="lh-preview__identity">
<div className="lh-avatar" aria-hidden="true">
{personaCatalogUnavailable ? "!" : personaInitial(selected)}
</div>
<div className="lh-preview__copy">
<span className="lh-preview__code">
{personaCatalogUnavailable ? "catalog" : (selected?.code ?? "...")}
</span>
<h2 id="lh-preview-title">
{personaCatalogUnavailable
? "사용 가능한 내담자가 없습니다."
: (selected?.display_name ?? "내담자 정보를 불러오는 중")}
</h2>
<p>
{personaCatalogUnavailable
? "데이터베이스에서 확인된 내담자만 연습에 사용할 수 있습니다."
: demographicsLine(selected)}
</p>
</div>
</div>
<div className="lh-preview__copy">
<span className="lh-preview__code">
{personaCatalogUnavailable ? "catalog" : (selected?.code ?? "...")}
<div className="lh-actions">
<span className="lh-actions__note">
.
</span>
<h2 id="lh-preview-title">
{personaCatalogUnavailable
? "사용 가능한 내담자가 없습니다."
: (selected?.display_name ?? "내담자 정보를 불러오는 중")}
</h2>
<p>
{personaCatalogUnavailable
? "데이터베이스에서 확인된 내담자만 연습에 사용할 수 있습니다."
: demographicsLine(selected)}
</p>
<Button
size="lg"
onClick={startPractice}
disabled={!selected}
trailing={<Icon name="chevron-right" size={18} />}
>
</Button>
</div>
</div>
@ -301,20 +325,6 @@ export default function LearnerHome() {
<dd>{selected?.voice_preset ?? "기본"}</dd>
</div>
</dl>
<div className="lh-actions">
<span className="lh-actions__note">
.
</span>
<Button
size="lg"
onClick={startPractice}
disabled={!selected}
trailing={<Icon name="chevron-right" size={18} />}
>
</Button>
</div>
</div>
<section
@ -417,17 +427,16 @@ export default function LearnerHome() {
const LH_CSS = `
.lh-root{
width:min(100%,1720px);
min-height:calc(100vh - var(--topbar-h));
width:min(100%,1480px);
min-height:calc(100dvh - var(--topbar-h));
margin:0 auto;
overflow:visible;
display:grid;
grid-template-rows:auto auto;
grid-template-rows:auto minmax(0,1fr);
align-content:start;
gap:var(--sp-4);
padding:clamp(14px,2vw,24px);
gap:clamp(12px,1.6vw,20px);
padding:clamp(14px,2vw,28px);
background:var(--bg-app);
overflow:visible;
}
.lh-head{
display:flex;
@ -439,7 +448,7 @@ const LH_CSS = `
.lh-head__copy{min-width:0;}
.lh-head__status{
flex:none;
min-height:36px;
min-height:34px;
display:inline-flex;
align-items:center;
gap:8px;
@ -453,15 +462,15 @@ const LH_CSS = `
font-weight:750;
}
.lh-title{
margin:6px 0 0;
font-size:clamp(22px,2.6vw,30px);
margin:5px 0 0;
font-size:clamp(22px,2.4vw,30px);
font-weight:740;
letter-spacing:0;
line-height:1.18;
color:var(--text-strong);
}
.lh-sub{
margin:7px 0 0;
margin:6px 0 0;
max-width:760px;
color:var(--text-body);
font-size:var(--fs-sm);
@ -469,23 +478,27 @@ const LH_CSS = `
}
.lh-workspace{
min-width:0;
min-height:0;
display:grid;
grid-template-columns:minmax(280px,320px) minmax(0,1fr);
gap:var(--sp-4);
grid-template-columns:minmax(300px,360px) minmax(0,1fr);
gap:clamp(14px,1.8vw,24px);
align-items:start;
}
.lh-list-pane,
.lh-preview{
min-width:0;
min-height:0;
}
.lh-list-pane{
position:sticky;
top:calc(var(--topbar-h) + 18px);
max-height:calc(100dvh - var(--topbar-h) - 36px);
display:grid;
grid-template-rows:auto auto;
padding:var(--sp-4) var(--sp-4) var(--sp-4) 0;
border-right:1px solid var(--hair);
overflow:visible;
grid-template-rows:auto minmax(0,1fr);
padding:var(--sp-4);
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
overflow:hidden;
}
.lh-pane-head{
display:flex;
@ -499,44 +512,48 @@ const LH_CSS = `
font-weight:700;
color:var(--text-muted);
font-family:var(--font-num);
white-space:nowrap;
}
.lh-personas{
min-height:0;
display:grid;
grid-auto-rows:auto;
align-content:start;
gap:10px;
gap:8px;
overflow:auto;
scrollbar-gutter:stable;
padding-right:2px;
max-height:calc(100dvh - 250px);
padding-right:3px;
}
.lh-persona{
position:relative;
min-width:0;
min-height:104px;
min-height:92px;
display:grid;
grid-template-columns:48px minmax(0,1fr);
gap:12px;
grid-template-columns:44px minmax(0,1fr);
gap:11px;
align-items:start;
padding:14px;
padding:12px 38px 12px 12px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
background:var(--bg-surface-2);
color:var(--text-body);
text-align:left;
cursor:pointer;
transition:background var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out), box-shadow var(--dur-fast) var(--ease-out);
}
.lh-persona:hover{border-color:var(--neutral-200);}
.lh-persona:hover{border-color:var(--border-strong);}
.lh-persona:disabled{
cursor:not-allowed;
opacity:.68;
}
.lh-persona.is-selected{
border-color:var(--accent);
background:var(--accent-tint);
box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 12%,transparent);
}
.lh-persona__mark{
width:42px;
height:42px;
width:40px;
height:40px;
display:grid;
place-items:center;
color:var(--text-strong);
@ -554,23 +571,26 @@ const LH_CSS = `
}
.lh-persona__name{
color:var(--text-strong);
font-size:16px;
font-size:14.5px;
font-weight:760;
line-height:1.25;
overflow:hidden;
display:-webkit-box;
-webkit-line-clamp:2;
-webkit-line-clamp:1;
-webkit-box-orient:vertical;
}
.lh-persona__meta{
color:var(--text-muted);
font-size:12.5px;
font-size:12px;
line-height:1.45;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.lh-persona__summary{
min-width:0;
color:var(--text-body);
font-size:13.5px;
font-size:12.5px;
line-height:1.45;
overflow:hidden;
display:-webkit-box;
@ -579,8 +599,8 @@ const LH_CSS = `
}
.lh-persona__check{
position:absolute;
top:12px;
right:12px;
top:10px;
right:10px;
width:22px;
height:22px;
display:grid;
@ -591,42 +611,75 @@ const LH_CSS = `
}
.lh-persona.is-skel{
pointer-events:none;
}
/* 실제 카드 구조(마크 + 이름/메타/요약 2줄)를 모사한 플레이스홀더 셰이머 */
.lh-skel__box,
.lh-skel__line{
background:linear-gradient(90deg,var(--bg-surface-2),var(--neutral-100),var(--bg-surface-2));
background-size:180% 100%;
animation:lhSkel 1.1s ease-in-out infinite;
}
.lh-skel__line{
height:11px;
border-radius:6px;
align-self:center;
}
.lh-skel__line--name{width:62%;height:13px;}
.lh-skel__line--meta{width:44%;height:10px;}
.lh-skel__line--sum{width:92%;}
.lh-skel__line--sum2{width:76%;}
.lh-preview{
display:grid;
grid-template-columns:minmax(0,1fr);
gap:var(--sp-4);
gap:clamp(14px,1.8vw,24px);
align-content:start;
align-items:start;
padding:clamp(14px,2vw,22px) 0 clamp(14px,2vw,22px) var(--sp-4);
overflow:visible;
}
.lh-preview__main{
min-width:0;
display:grid;
gap:var(--sp-4);
align-content:start;
padding:var(--sp-5);
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.lh-preview__hero{
min-width:0;
display:grid;
grid-template-columns:minmax(0,1fr) minmax(220px,auto);
gap:var(--sp-4);
align-items:start;
}
@media (min-width:1400px) and (max-width:1559px){
.lh-preview__hero{
grid-template-columns:minmax(0,1fr);
}
.lh-actions{
justify-items:start;
}
.lh-actions__note{
text-align:left;
}
}
.lh-preview__identity{
flex:none;
min-width:0;
display:grid;
grid-template-columns:auto minmax(0,1fr);
align-items:center;
align-items:start;
gap:var(--sp-4);
}
.lh-avatar{
width:82px;
height:82px;
width:64px;
height:64px;
display:grid;
place-items:center;
border-radius:50%;
background:var(--clay-tint);
color:var(--clay-deep);
font-size:32px;
font-size:26px;
font-weight:780;
box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--clay) 22%,transparent);
}
@ -636,7 +689,7 @@ const LH_CSS = `
align-items:center;
min-height:24px;
padding:0 9px;
border-radius:999px;
border-radius:var(--radius-sm);
background:var(--accent-tint);
color:var(--accent-deep);
font-family:var(--font-num);
@ -644,51 +697,55 @@ const LH_CSS = `
font-weight:800;
}
.lh-preview h2{
margin:10px 0 0;
margin:8px 0 0;
color:var(--text-strong);
font-size:clamp(23px,3vw,34px);
line-height:1.18;
font-size:clamp(22px,2.4vw,30px);
line-height:1.2;
letter-spacing:0;
word-break:keep-all;
overflow-wrap:anywhere;
overflow:hidden;
display:-webkit-box;
-webkit-line-clamp:2;
-webkit-box-orient:vertical;
}
.lh-preview__copy p{
margin:8px 0 0;
margin:7px 0 0;
color:var(--text-muted);
font-size:var(--fs-sm);
line-height:1.45;
word-break:keep-all;
overflow-wrap:anywhere;
}
.lh-summary{
flex:none;
min-width:0;
padding:var(--sp-5);
padding:var(--sp-4);
border-radius:var(--radius);
background:var(--bg-surface-2);
}
.lh-summary p{
margin:8px 0 0;
color:var(--text-body);
font-size:clamp(16px,1.6vw,20px);
line-height:1.55;
font-size:clamp(15px,1.2vw,18px);
line-height:1.52;
overflow:hidden;
display:-webkit-box;
-webkit-line-clamp:3;
-webkit-line-clamp:4;
-webkit-box-orient:vertical;
}
.lh-facts{
flex:none;
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:var(--sp-3);
gap:10px;
margin:0;
}
.lh-facts div{
min-width:0;
padding:0 0 var(--sp-3);
min-height:54px;
border-bottom:1px solid var(--hair);
min-height:64px;
padding:12px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
}
.lh-facts dt{
margin:0;
@ -707,14 +764,15 @@ const LH_CSS = `
white-space:nowrap;
}
.lh-activity{
flex:none;
min-height:0;
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:var(--sp-3);
gap:var(--sp-4);
padding:var(--sp-5);
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.lh-activity__stats{
grid-column:1/-1;
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:8px;
@ -724,18 +782,20 @@ const LH_CSS = `
min-width:0;
}
.lh-activity__stats span{
min-height:44px;
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
padding:8px 0;
border-bottom:1px solid var(--hair);
min-height:58px;
display:grid;
align-content:center;
gap:4px;
padding:10px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface-2);
}
.lh-activity__stats b{
color:var(--text-strong);
font-family:var(--font-num);
font-size:20px;
font-size:22px;
line-height:1;
}
.lh-activity__stats small{
color:var(--text-muted);
@ -743,11 +803,7 @@ const LH_CSS = `
font-weight:720;
}
.lh-activity__recent{
grid-column:1/-1;
min-height:92px;
padding:var(--sp-3) 0 0;
border-top:1px solid var(--hair);
overflow:hidden;
min-height:0;
}
.lh-activity__head{
display:flex;
@ -761,6 +817,7 @@ const LH_CSS = `
font-family:var(--font-num);
font-size:12px;
font-weight:800;
white-space:nowrap;
}
.lh-activity__recent ul{
list-style:none;
@ -771,15 +828,15 @@ const LH_CSS = `
}
.lh-activity__recent li{
min-width:0;
display:flex;
display:grid;
grid-template-columns:minmax(0,1fr) auto;
gap:6px 10px;
align-items:center;
justify-content:space-between;
gap:var(--sp-3);
padding:8px 0;
padding:10px 0;
border-top:1px solid var(--hair);
}
.lh-activity__recent li:first-child{border-top:0;padding-top:0;}
.lh-activity__recent li span{
.lh-activity__recent li > span:first-child{
min-width:0;
display:grid;
gap:2px;
@ -795,16 +852,18 @@ const LH_CSS = `
}
.lh-activity__recent p{margin:0;line-height:1.45;}
.lh-activity__recent em{
flex:none;
grid-column:1;
color:var(--text-body);
font-family:var(--font-num);
font-size:12px;
font-style:normal;
}
.lh-session-actions{
flex:none;
grid-column:2;
grid-row:1 / span 2;
display:flex;
align-items:center;
justify-content:flex-end;
gap:6px;
}
.lh-activity__unavailable{
@ -850,8 +909,10 @@ const LH_CSS = `
font-family:var(--font-sans);
font-size:12px;
font-weight:720;
padding:5px 8px;
min-height:30px;
padding:5px 9px;
cursor:pointer;
white-space:nowrap;
}
.lh-review-link:hover{background:var(--accent-tint);}
.lh-review-link--ghost{
@ -859,24 +920,21 @@ const LH_CSS = `
}
.lh-review-link--ghost:hover{color:var(--accent-deep);}
.lh-actions{
margin-top:0;
display:flex;
align-items:center;
justify-content:flex-end;
gap:var(--sp-4);
padding-top:var(--sp-4);
border-top:1px solid var(--hair);
}
.lh-root.has-session-records .lh-actions{
margin-top:0;
min-width:0;
display:grid;
justify-items:end;
gap:10px;
}
.lh-actions__note{
display:none;
display:block;
min-width:0;
max-width:620px;
max-width:260px;
color:var(--text-muted);
font-size:12.5px;
line-height:1.45;
text-align:right;
word-break:keep-all;
text-wrap:balance;
}
.lh-error{
display:flex;
@ -890,73 +948,67 @@ const LH_CSS = `
.lh-error b{display:block;}
.lh-error p{margin:4px 0 0;font-size:var(--fs-sm);line-height:1.5;}
@keyframes lhSkel{0%{background-position:120% 0}100%{background-position:-80% 0}}
@media (min-width:1180px){
.lh-root.has-session-records .lh-preview{
grid-template-columns:minmax(0,1fr) minmax(320px,380px);
@media (min-width:1400px){
.lh-preview{
grid-template-columns:minmax(440px,1fr) minmax(340px,400px);
column-gap:var(--sp-5);
}
.lh-root.has-session-records .lh-preview__main{
grid-column:1;
.lh-activity{
position:sticky;
top:calc(var(--topbar-h) + 18px);
}
.lh-root.has-session-records .lh-activity{
grid-column:2;
grid-row:1;
align-self:start;
padding:var(--sp-4);
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.lh-root.has-session-records .lh-activity__stats{
.lh-activity__stats{
grid-template-columns:1fr;
}
.lh-root.has-session-records .lh-activity__stats span{
min-height:38px;
padding:6px 0;
}
.lh-root.has-session-records .lh-activity__recent{
border-top:0;
padding-top:var(--sp-3);
}
.lh-root.has-session-records .lh-activity__recent li{
display:grid;
grid-template-columns:minmax(0,1fr) auto;
gap:4px 10px;
}
.lh-root.has-session-records .lh-activity__recent li em{
grid-column:1;
grid-row:2;
font-size:11px;
}
.lh-root.has-session-records .lh-session-actions{
grid-column:2;
grid-row:1 / span 2;
flex-direction:column;
align-items:stretch;
}
}
@media (max-width:920px){
.lh-root{padding:12px;gap:12px;}
@media (max-width:1080px){
.lh-workspace{
grid-template-columns:1fr;
grid-template-rows:auto auto;
gap:12px;
}
.lh-list-pane{
padding:0;
border-right:0;
border-bottom:1px solid var(--hair);
position:relative;
top:auto;
max-height:none;
overflow:visible;
}
.lh-personas{
grid-template-columns:repeat(2,minmax(0,1fr));
grid-auto-rows:auto;
overflow:visible;
padding-right:0;
}
.lh-preview{
grid-template-columns:1fr;
}
.lh-activity__stats{
grid-template-columns:repeat(3,minmax(0,1fr));
}
}
@media (max-width:760px){
.lh-root{
padding:12px;
gap:12px;
}
.lh-head{
align-items:flex-start;
}
.lh-list-pane{
padding:12px;
}
.lh-pane-head{margin-bottom:10px;}
.lh-personas{
grid-auto-rows:minmax(58px,1fr);
display:flex;
grid-template-columns:none;
gap:8px;
overflow:auto;
overflow-x:auto;
overflow-y:hidden;
padding-right:0;
padding-bottom:2px;
scrollbar-gutter:auto;
}
.lh-persona{
min-height:58px;
flex:0 0 min(82vw,320px);
min-height:64px;
grid-template-columns:38px minmax(0,1fr);
align-items:center;
gap:10px;
@ -967,19 +1019,39 @@ const LH_CSS = `
.lh-persona__meta{font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.lh-persona__summary{display:none;}
.lh-persona__check{top:50%;right:10px;transform:translateY(-50%);}
.lh-preview{gap:10px;padding:0;overflow:visible;scrollbar-gutter:stable;}
.lh-preview__main{gap:10px;}
.lh-preview{
gap:12px;
}
.lh-preview__main,
.lh-activity{
padding:14px;
}
.lh-preview__hero{
grid-template-columns:1fr;
gap:12px;
}
.lh-preview__identity{grid-template-columns:54px minmax(0,1fr);gap:12px;}
.lh-avatar{width:54px;height:54px;font-size:22px;}
.lh-preview h2{margin-top:6px;font-size:20px;-webkit-line-clamp:1;}
.lh-preview h2{margin-top:6px;font-size:20px;-webkit-line-clamp:2;}
.lh-preview__copy p{margin-top:5px;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.lh-actions{
justify-items:stretch;
}
.lh-actions__note{
max-width:none;
text-align:left;
}
.lh-actions .vg-btn{
width:100%;
justify-content:center;
}
.lh-summary{padding:12px;}
.lh-summary p{margin-top:6px;font-size:14px;line-height:1.45;-webkit-line-clamp:2;}
.lh-facts{grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;}
.lh-facts div{padding:10px;}
.lh-facts div{min-height:58px;padding:10px;}
.lh-facts dt{font-size:11px;}
.lh-facts dd{font-size:12px;}
.lh-activity{grid-template-columns:1fr;gap:8px;}
.lh-activity{gap:12px;}
.lh-activity__stats{grid-template-columns:repeat(3,minmax(0,1fr));}
.lh-activity__stats span{
min-height:46px;
@ -990,22 +1062,10 @@ const LH_CSS = `
}
.lh-activity__stats b{font-size:17px;}
.lh-activity__stats small{font-size:11px;}
.lh-activity__recent{padding-top:8px;}
.lh-activity__recent li:nth-child(n+3){display:none;}
.lh-session-actions{gap:4px;}
.lh-review-link{padding:4px 7px;}
.lh-actions{padding-top:10px;}
.lh-actions__note{display:none;}
}
@media (max-width:560px){
.lh-root{
height:calc(100vh - var(--topbar-h));
height:calc(100dvh - var(--topbar-h));
min-height:0;
overflow:hidden;
grid-template-rows:auto minmax(0,1fr);
align-content:stretch;
}
.lh-head__status{display:none;}
.lh-head .vg-kicker,
.lh-pane-head .vg-kicker,
@ -1014,41 +1074,17 @@ const LH_CSS = `
font-size:11px;
gap:6px;
}
.lh-title{margin-top:3px;font-size:20px;line-height:1.16;}
.lh-title{margin-top:3px;font-size:20px;line-height:1.18;}
.lh-sub{
margin-top:4px;
font-size:12.5px;
line-height:1.25;
overflow:hidden;
display:-webkit-box;
-webkit-line-clamp:1;
-webkit-box-orient:vertical;
line-height:1.35;
}
.lh-head{gap:6px;}
.lh-workspace{
min-height:0;
overflow:hidden;
grid-template-rows:auto minmax(0,1fr);
}
.lh-list-pane{
min-height:0;
overflow:hidden;
padding-bottom:6px;
}
.lh-pane-head{margin-bottom:5px;}
.lh-personas{
display:flex;
grid-auto-rows:unset;
max-height:none;
overflow-x:auto;
overflow-y:hidden;
gap:8px;
padding:0 0 3px;
scrollbar-gutter:auto;
}
.lh-persona{
flex:0 0 min(78vw,300px);
min-height:48px;
min-height:56px;
width:min(78vw,300px);
grid-template-columns:30px minmax(0,1fr);
padding:7px 34px 7px 7px;
@ -1056,46 +1092,24 @@ const LH_CSS = `
.lh-persona__mark{width:28px;height:28px;font-size:11px;}
.lh-persona__name{font-size:13px;}
.lh-persona__meta{font-size:11px;}
.lh-preview{min-height:0;gap:7px;padding:0;overflow:hidden;}
.lh-preview__main{gap:7px;}
.lh-root.has-session-records .lh-preview{
gap:6px;
}
.lh-root.has-session-records .lh-preview__main{
gap:6px;
}
.lh-preview{gap:10px;}
.lh-preview__main{gap:10px;}
.lh-preview__identity{grid-template-columns:38px minmax(0,1fr);gap:9px;}
.lh-avatar{width:38px;height:38px;font-size:17px;}
.lh-root.has-session-records .lh-avatar{
width:34px;
height:34px;
font-size:15px;
}
.lh-preview h2{margin-top:4px;font-size:17px;line-height:1.18;}
.lh-root.has-session-records .lh-preview h2{
font-size:16px;
}
.lh-preview h2{margin-top:4px;font-size:17px;line-height:1.2;}
.lh-preview__code{min-height:20px;font-size:10.5px;padding:0 8px;}
.lh-preview__copy p{margin-top:4px;font-size:11.5px;}
.lh-summary{
display:flex;
align-items:center;
gap:9px;
padding:8px 9px;
}
.lh-summary .vg-kicker{flex:none;}
.lh-summary p{margin:0;font-size:12px;line-height:1.32;-webkit-line-clamp:1;}
.lh-root.has-session-records .lh-summary,
.lh-root.has-session-records .lh-facts{
display:none;
}
.lh-facts{grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;}
.lh-actions__note{display:none;}
.lh-summary{padding:10px;}
.lh-summary p{font-size:12.5px;line-height:1.38;-webkit-line-clamp:2;}
.lh-facts{grid-template-columns:1fr;gap:6px;}
.lh-facts div{
min-height:26px;
min-height:0;
display:flex;
align-items:center;
justify-content:space-between;
gap:6px;
padding:0 0 5px;
padding:8px 10px;
}
.lh-facts dt{font-size:10.5px;}
.lh-facts dd{
@ -1106,17 +1120,14 @@ const LH_CSS = `
display:block;
text-overflow:ellipsis;
}
.lh-activity{padding:12px;gap:10px;}
.lh-activity__stats{gap:6px;}
.lh-activity__stats span{
min-height:34px;
padding:4px 0;
min-height:40px;
padding:6px 4px;
}
.lh-activity__stats b{font-size:16px;}
.lh-activity__stats small{font-size:10.5px;}
.lh-activity__recent{display:block;max-height:116px;}
.lh-root.has-session-records .lh-activity__recent{
max-height:none;
padding-top:6px;
}
.lh-activity__head{margin-bottom:6px;}
.lh-activity__empty{
padding:8px;
@ -1139,30 +1150,14 @@ const LH_CSS = `
}
.lh-activity__recent li em{display:none;}
.lh-session-actions{grid-column:2;grid-row:1 / span 2;flex-direction:column;align-items:stretch;}
.lh-actions{padding-top:6px;}
.lh-actions .vg-btn{
min-height:42px;
height:42px;
min-height:40px;
font-size:14px;
padding:0 12px;
}
.lh-root.has-session-records .lh-actions{
flex:none;
margin-top:0;
}
.lh-actions .vg-btn{width:100%;justify-content:center;}
.lh-root.has-session-records .lh-actions{
align-items:stretch;
padding-top:6px;
}
.lh-root.has-session-records .lh-actions .vg-btn{
min-height:36px;
height:36px;
font-size:13px;
padding:0 12px;
}
}
@media (prefers-reduced-motion:reduce){
.lh-persona.is-skel{animation:none;}
.lh-skel__box,
.lh-skel__line{animation:none;}
}
`;

View file

@ -1,7 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { AppShell } from "../components/shell/AppShell";
import { Badge, Button, Card, Dot, Icon, Kicker } from "../components/ui";
import { teacherApi, type TeacherDashboardResponse } from "../lib/api";
import {
ApiError,
personaReviewApi,
teacherApi,
type PersonaReviewAction,
type PersonaReviewStatus,
type PersonaReviewSummary,
type TeacherDashboardResponse,
} from "../lib/api";
type LoadState = "loading" | "ready" | "error";
@ -17,6 +25,19 @@ function formatDateTime(value: string | null): string {
});
}
function personaReviewStatusLabel(status: PersonaReviewStatus): string {
if (status === "review") return "검수 대기";
if (status === "draft") return "수정 대기";
if (status === "approved") return "승인됨";
return "보관됨";
}
function personaReviewTone(status: PersonaReviewStatus): "accent" | "neutral" | "warn" {
if (status === "review") return "accent";
if (status === "draft") return "warn";
return "neutral";
}
function EmptyState({ title, desc }: { title: string; desc: string }) {
return (
<div className="pf-empty">
@ -28,8 +49,12 @@ function EmptyState({ title, desc }: { title: string; desc: string }) {
export default function Professor() {
const [dashboard, setDashboard] = useState<TeacherDashboardResponse | null>(null);
const [personaReviews, setPersonaReviews] = useState<PersonaReviewSummary[]>([]);
const [loadState, setLoadState] = useState<LoadState>("loading");
const [error, setError] = useState<string | null>(null);
const [personaReviewLoading, setPersonaReviewLoading] = useState(true);
const [personaReviewError, setPersonaReviewError] = useState<string | null>(null);
const [personaReviewBusy, setPersonaReviewBusy] = useState<string | null>(null);
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
const loadDashboard = useCallback(async () => {
@ -46,9 +71,53 @@ export default function Professor() {
}
}, []);
const loadPersonaReviews = useCallback(async () => {
setPersonaReviewLoading(true);
setPersonaReviewError(null);
try {
const next = await personaReviewApi.list();
setPersonaReviews(next);
} catch (err) {
// 404(검수 엔드포인트 부재/검수 데이터 없음)는 장애가 아니므로
// 빨간 에러 배너 대신 정상 빈 상태로만 표시한다.
if (err instanceof ApiError && err.status === 404) {
setPersonaReviews([]);
} else {
// 진짜 장애(5xx/네트워크)일 때만 정돈된 에러 카드를 노출한다.
setPersonaReviewError("검수 데이터를 불러오지 못했습니다.");
}
} finally {
setPersonaReviewLoading(false);
}
}, []);
useEffect(() => {
void loadDashboard();
}, [loadDashboard]);
void loadPersonaReviews();
}, [loadDashboard, loadPersonaReviews]);
const decidePersonaReview = useCallback(
async (personaId: string, action: PersonaReviewAction) => {
setPersonaReviewBusy(`${personaId}:${action}`);
setPersonaReviewError(null);
try {
const updated = await personaReviewApi.decide(personaId, action);
setPersonaReviews((current) => {
if (updated.status === "approved" || updated.status === "archived") {
return current.filter((item) => item.persona_id !== updated.persona_id);
}
return current.map((item) => (item.persona_id === updated.persona_id ? updated : item));
});
} catch (err) {
setPersonaReviewError(
err instanceof Error ? err.message : "페르소나 검수 결정을 저장하지 못했습니다.",
);
} finally {
setPersonaReviewBusy(null);
}
},
[],
);
const kpis = useMemo(
() => [
@ -82,6 +151,7 @@ export default function Professor() {
const pendingCount = dashboard?.pending_reviews.length ?? 0;
const hasPending = pendingCount > 0;
const totalSessions = (dashboard?.active_sessions ?? 0) + (dashboard?.ended_sessions ?? 0);
const personaReviewCount = personaReviews.length;
return (
<AppShell>
@ -101,31 +171,51 @@ export default function Professor() {
<Button
variant="secondary"
leading={<Icon name="review" size={16} />}
onClick={() => void loadDashboard()}
disabled={loadState === "loading"}
onClick={() => {
void loadDashboard();
void loadPersonaReviews();
}}
disabled={loadState === "loading" || personaReviewLoading}
>
{loadState === "loading" || personaReviewLoading ? "확인 중" : "새로고침"}
</Button>
</header>
<section className={`pf-triage ${hasPending ? "is-active" : ""}`} role="status">
<Dot tone={loadState === "error" ? "crit" : "accent"} size={9} />
<div className="pf-triage__copy">
<Kicker> </Kicker>
<b>{dashboard?.message ?? "학습 세션을 확인하는 중입니다."}</b>
<span>{dashboard ? `${dashboard.cohort_label} · 실제 기록` : "확인 중"}</span>
</div>
<div className="pf-triage__meta">
<span>
<b>{pendingCount}</b>
<small> </small>
</span>
<span>
<b>{totalSessions}</b>
<small> </small>
</span>
<time>{updatedAt ? updatedAt.toLocaleTimeString("ko-KR") : ""}</time>
</div>
<section className="pf-signal-strip" aria-label="교수자 검토 요약">
<section className={`pf-triage ${hasPending ? "is-active" : ""}`} role="status">
<Dot tone={loadState === "error" ? "crit" : "accent"} size={9} />
<div className="pf-triage__copy">
<Kicker> </Kicker>
<b>{dashboard?.message ?? "학습 세션을 확인하는 중입니다."}</b>
<span>
{dashboard ? `${dashboard.cohort_label} · 실제 기록` : "확인 중"}
{updatedAt ? ` · ${updatedAt.toLocaleTimeString("ko-KR")} 갱신` : ""}
</span>
</div>
<div className="pf-triage__meta">
<span>
<b>{pendingCount}</b>
<small> </small>
</span>
<span>
<b>{totalSessions}</b>
<small> </small>
</span>
</div>
</section>
<section className="pf-kpis" aria-label="교수자 요약">
{kpis.map((kpi) => (
<div className="pf-kpi" key={kpi.label}>
<span className="pf-kpi__ic" aria-hidden="true">
<Icon name={kpi.icon} size={15} strokeWidth={1.9} />
</span>
<span className="pf-kpi__lab">{kpi.label}</span>
<b>{kpi.value}</b>
<small>{kpi.hint}</small>
</div>
))}
</section>
</section>
{error ? (
@ -135,20 +225,97 @@ export default function Professor() {
</section>
) : null}
<section className="pf-kpis" aria-label="교수자 요약">
{kpis.map((kpi) => (
<div className="pf-kpi" key={kpi.label}>
<span className="pf-kpi__ic" aria-hidden="true">
<Icon name={kpi.icon} size={15} strokeWidth={1.9} />
</span>
<span className="pf-kpi__lab">{kpi.label}</span>
<b>{kpi.value}</b>
<small>{kpi.hint}</small>
<section className="pf-workspace">
<div className="pf-queue-stack">
<section className="pf-section">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2> </h2>
</div>
))}
</section>
<Badge tone={personaReviewCount > 0 ? "accent" : "neutral"}>
{personaReviewCount}
</Badge>
</div>
<section className="pf-section">
<Card className="pf-panel">
{personaReviewError ? (
<div className="pf-empty" role="alert">
<Icon name="alert" size={18} />
<b>{personaReviewError}</b>
<span> .</span>
<Button
size="sm"
variant="secondary"
leading={<Icon name="review" size={14} />}
onClick={() => void loadPersonaReviews()}
disabled={personaReviewLoading}
>
{personaReviewLoading ? "확인 중" : "다시 시도"}
</Button>
</div>
) : personaReviews.length > 0 ? (
<div className="pf-personas">
{personaReviews.map((persona) => {
const approveKey = `${persona.persona_id}:approve`;
const rejectKey = `${persona.persona_id}:reject`;
const busy = personaReviewBusy?.startsWith(`${persona.persona_id}:`) ?? false;
return (
<article
className="pf-persona"
key={`${persona.persona_id}:${persona.version}`}
data-persona-review-row="true"
>
<div className="pf-persona__main">
<span className="pf-persona__code">{persona.code}</span>
<div>
<b>{persona.display_name}</b>
<span>
v{persona.version} · {persona.difficulty} ·{" "}
{persona.theory_target.join(" · ") || "공통"}
</span>
</div>
</div>
<p>{persona.source_provenance || "출처 정보 없음"}</p>
<div className="pf-persona__meta">
<Badge tone={personaReviewTone(persona.status)}>
{personaReviewStatusLabel(persona.status)}
</Badge>
<span>{formatDateTime(persona.created_at)}</span>
</div>
<div className="pf-persona__actions">
<Button
size="sm"
variant="secondary"
leading={<Icon name="x" size={14} />}
onClick={() => void decidePersonaReview(persona.persona_id, "reject")}
disabled={busy}
>
{personaReviewBusy === rejectKey ? "저장 중" : "반려"}
</Button>
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void decidePersonaReview(persona.persona_id, "approve")}
disabled={busy}
>
{personaReviewBusy === approveKey ? "저장 중" : "승인"}
</Button>
</div>
</article>
);
})}
</div>
) : (
<EmptyState
title={personaReviewLoading ? "페르소나 검수 큐 확인 중" : "검수 대기 페르소나 없음"}
desc="draft 또는 review 상태의 페르소나가 등록되면 이 목록에서 승인하거나 반려합니다."
/>
)}
</Card>
</section>
<section className="pf-section">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
@ -184,9 +351,10 @@ export default function Professor() {
/>
)}
</Card>
</section>
</section>
</div>
<section className="pf-section">
<section className="pf-section pf-section--recent">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
@ -197,42 +365,48 @@ export default function Professor() {
<Card className="pf-panel">
{dashboard && dashboard.recent_sessions.length > 0 ? (
<div className="pf-tablewrap">
<table className="pf-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{dashboard.recent_sessions.map((session) => (
<tr key={session.session_id}>
<td>
<span className="pf-learner">
{session.learner_label}
<code>{session.session_id}</code>
</span>
</td>
<td>{session.persona_code}</td>
<td>
<Badge tone={session.status === "ended" ? "neutral" : "accent"}>
{session.status === "ended" ? "종료" : "진행 중"}
</Badge>
</td>
<td>{session.stage}</td>
<td className="tabular">{session.turn_count}</td>
<td className="tabular">{formatDateTime(session.started_at)}</td>
<td className="tabular">{formatDateTime(session.ended_at)}</td>
</tr>
))}
</tbody>
</table>
<div className="pf-recent-list">
<div className="pf-recent-head" aria-hidden="true">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{dashboard.recent_sessions.map((session) => (
<article
className="pf-recent-row"
key={session.session_id}
data-recent-session-row="true"
>
<div className="pf-recent__learner">
<b>{session.learner_label}</b>
<code>{session.session_id}</code>
</div>
<span className="pf-recent__cell" data-label="페르소나">
{session.persona_code}
</span>
<span className="pf-recent__cell" data-label="상태">
<Badge tone={session.status === "ended" ? "neutral" : "accent"}>
{session.status === "ended" ? "종료" : "진행 중"}
</Badge>
</span>
<span className="pf-recent__cell" data-label="단계">
{session.stage}
</span>
<span className="pf-recent__cell tabular" data-label="턴">
{session.turn_count}
</span>
<span className="pf-recent__cell tabular" data-label="시작">
{formatDateTime(session.started_at)}
</span>
<span className="pf-recent__cell tabular" data-label="종료">
{formatDateTime(session.ended_at)}
</span>
</article>
))}
</div>
) : (
<EmptyState
@ -241,6 +415,7 @@ export default function Professor() {
/>
)}
</Card>
</section>
</section>
</main>
</AppShell>
@ -253,99 +428,97 @@ const PF_CSS = `
margin:0 auto;
display:flex;
flex-direction:column;
gap:var(--sp-6);
gap:18px;
}
.pf-head{
display:flex;
align-items:flex-end;
justify-content:space-between;
gap:var(--sp-5);
gap:var(--sp-4);
flex-wrap:wrap;
}
.pf-head h1{
margin:8px 0 0;
margin:6px 0 0;
color:var(--text-strong);
font-size:var(--fs-h1);
line-height:1.24;
font-size:var(--fs-h2);
line-height:1.28;
letter-spacing:0;
}
.pf-head p{
margin:10px 0 0;
max-width:760px;
margin:6px 0 0;
max-width:680px;
color:var(--text-body);
font-size:var(--fs-sm);
line-height:1.65;
font-size:var(--fs-xs);
line-height:1.55;
}
.pf-signal-strip{
display:grid;
grid-template-columns:minmax(0,1.45fr) minmax(300px,.75fr);
gap:12px;
min-width:0;
}
.pf-triage{
display:grid;
grid-template-columns:auto minmax(0,1fr) auto;
align-items:center;
gap:var(--sp-4);
padding:var(--sp-5);
gap:12px;
min-width:0;
padding:14px 16px;
border:1px solid var(--hair);
border-radius:var(--radius-lg);
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.pf-triage.is-active{
background:linear-gradient(135deg,var(--bg-surface) 0%,var(--accent-tint) 100%);
background:color-mix(in srgb,var(--accent-tint) 42%,var(--bg-surface));
}
.pf-triage__copy{
min-width:0;
}
.pf-triage__copy .vg-kicker{
margin-bottom:6px;
margin-bottom:4px;
}
.pf-triage__copy b{
display:block;
color:var(--text-strong);
font-size:var(--fs-lead);
line-height:1.4;
font-size:var(--fs-body);
line-height:1.35;
}
.pf-triage__copy span,
.pf-triage__meta small,
.pf-triage__meta time{
.pf-triage__meta small{
color:var(--text-muted);
font-size:var(--fs-xs);
}
.pf-triage__meta{
display:flex;
align-items:stretch;
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:0;
min-width:max-content;
min-width:180px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:color-mix(in srgb,var(--bg-surface) 86%,transparent);
overflow:hidden;
}
.pf-triage__meta span,
.pf-triage__meta time{
min-width:96px;
.pf-triage__meta span{
display:grid;
align-content:center;
gap:2px;
padding:10px 14px;
padding:9px 12px;
}
.pf-triage__meta span + span,
.pf-triage__meta time{
.pf-triage__meta span + span{
border-left:1px solid var(--hair);
}
.pf-triage__meta b{
color:var(--text-strong);
font-family:var(--font-num);
font-size:18px;
font-size:17px;
line-height:1;
}
.pf-triage__meta time{
font-family:var(--font-num);
white-space:nowrap;
text-align:right;
}
.pf-error{
display:flex;
align-items:center;
gap:var(--sp-3);
padding:var(--sp-4);
gap:10px;
padding:12px 14px;
border-radius:var(--radius);
background:var(--crit-tint);
color:var(--crit-text);
@ -353,9 +526,9 @@ const PF_CSS = `
}
.pf-kpis{
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
grid-template-columns:repeat(2,minmax(0,1fr));
border:1px solid var(--hair);
border-radius:var(--radius-lg);
border-radius:var(--radius);
overflow:hidden;
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
@ -363,17 +536,18 @@ const PF_CSS = `
.pf-kpi{
position:relative;
min-width:0;
padding:var(--sp-5);
padding:11px 12px;
display:grid;
grid-template-columns:minmax(0,1fr) auto;
gap:6px var(--sp-3);
gap:4px 10px;
}
.pf-kpi + .pf-kpi{border-left:1px solid var(--hair);}
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.pf-kpi__ic{
grid-column:2;
grid-row:1 / span 3;
width:34px;
height:34px;
width:30px;
height:30px;
display:grid;
place-items:center;
border-radius:var(--radius);
@ -390,7 +564,7 @@ const PF_CSS = `
display:block;
color:var(--text-strong);
font-family:var(--font-num);
font-size:30px;
font-size:23px;
line-height:1;
}
.pf-kpi small{
@ -398,21 +572,36 @@ const PF_CSS = `
font-size:12px;
line-height:1.35;
}
.pf-workspace{
display:grid;
grid-template-columns:minmax(330px,380px) minmax(0,1fr);
align-items:start;
gap:14px;
min-width:0;
}
.pf-queue-stack{
display:flex;
flex-direction:column;
gap:14px;
min-width:0;
}
.pf-section{
display:flex;
flex-direction:column;
gap:var(--sp-4);
gap:10px;
min-width:0;
}
.pf-section__head{
display:flex;
align-items:flex-end;
justify-content:space-between;
gap:var(--sp-4);
gap:12px;
}
.pf-section__head h2{
margin:6px 0 0;
margin:4px 0 0;
color:var(--text-strong);
font-size:var(--fs-h3);
font-size:var(--fs-body);
font-weight:700;
line-height:1.3;
letter-spacing:0;
}
@ -421,11 +610,11 @@ const PF_CSS = `
overflow:hidden;
}
.pf-empty{
min-height:156px;
min-height:118px;
display:grid;
place-items:center;
gap:6px;
padding:var(--sp-6);
padding:var(--sp-5);
text-align:center;
}
.pf-empty b{
@ -434,21 +623,96 @@ const PF_CSS = `
}
.pf-empty span{
color:var(--text-muted);
font-size:var(--fs-sm);
font-size:var(--fs-xs);
}
.pf-list{
max-height:min(430px,52vh);
max-height:min(320px,42vh);
overflow:auto;
scrollbar-gutter:stable;
display:flex;
flex-direction:column;
}
.pf-personas{
max-height:min(320px,42vh);
overflow:auto;
scrollbar-gutter:stable;
display:flex;
flex-direction:column;
}
.pf-persona{
display:grid;
grid-template-columns:minmax(0,1fr);
gap:8px;
align-items:start;
padding:12px;
border-top:1px solid var(--paper-2);
}
.pf-persona:first-child{border-top:0;}
.pf-persona__main{
min-width:0;
display:grid;
grid-template-columns:40px minmax(0,1fr);
gap:10px;
align-items:center;
}
.pf-persona__code{
width:40px;
height:32px;
display:grid;
place-items:center;
border-radius:var(--radius);
color:var(--accent-deep);
background:var(--accent-tint);
font-family:var(--font-num);
font-weight:800;
font-size:12px;
}
.pf-persona__main b{
display:block;
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-persona__main span,
.pf-persona p,
.pf-persona__meta span{
color:var(--text-muted);
font-size:var(--fs-xs);
line-height:1.45;
}
.pf-persona p{
margin:0;
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-persona__meta{
display:flex;
align-items:center;
gap:10px;
justify-content:space-between;
white-space:nowrap;
}
.pf-persona__actions{
display:flex;
justify-content:flex-end;
gap:8px;
min-width:0;
}
.pf-persona__actions .vg-btn{
min-width:72px;
padding-inline:10px;
}
.pf-session{
display:grid;
grid-template-columns:8px minmax(0,1fr) auto;
gap:var(--sp-4);
align-items:center;
padding:var(--sp-4) var(--sp-5);
grid-template-columns:8px minmax(0,1fr);
gap:8px 10px;
align-items:start;
padding:12px;
border-top:1px solid var(--paper-2);
}
.pf-session:first-child{border-top:0;}
@ -471,62 +735,112 @@ const PF_CSS = `
.pf-session__main span,.pf-session__main code{
color:var(--text-muted);
font-size:var(--fs-xs);
overflow-wrap:anywhere;
}
.pf-session__main code,.pf-learner code{
.pf-session__main code,.pf-recent__learner code{
font-family:var(--font-num);
overflow:hidden;
text-overflow:ellipsis;
}
.pf-session__meta{
grid-column:2;
display:flex;
align-items:center;
gap:var(--sp-3);
justify-content:space-between;
gap:8px;
color:var(--text-muted);
font-family:var(--font-num);
font-size:var(--fs-xs);
white-space:nowrap;
white-space:normal;
}
.pf-tablewrap{
max-height:min(480px,58vh);
.pf-recent-list{
max-height:min(620px,calc(100vh - 220px));
overflow:auto;
scrollbar-gutter:stable;
min-width:0;
}
.pf-table{
width:100%;
border-collapse:collapse;
min-width:760px;
.pf-recent-head,
.pf-recent-row{
display:grid;
grid-template-columns:minmax(155px,1.35fr) 82px 78px minmax(110px,.95fr) 42px 86px 86px;
align-items:center;
gap:9px;
min-width:0;
}
.pf-table th{
.pf-recent-head{
position:sticky;
top:0;
z-index:1;
text-align:left;
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
padding:var(--sp-4) var(--sp-5) var(--sp-3);
padding:9px 12px;
border-bottom:1px solid var(--hair);
background:var(--bg-surface);
white-space:nowrap;
}
.pf-table td{
.pf-recent-row{
padding:10px 12px;
border-top:1px solid var(--paper-2);
}
.pf-recent-head + .pf-recent-row{
border-top:0;
}
.pf-recent__learner,
.pf-recent__cell{
min-width:0;
color:var(--text-body);
font-size:var(--fs-sm);
padding:var(--sp-4) var(--sp-5);
border-bottom:1px solid var(--paper-2);
vertical-align:middle;
overflow-wrap:anywhere;
}
.pf-table tr:last-child td{border-bottom:0;}
.pf-learner{
.pf-recent__cell::before{
display:none;
}
.pf-recent__learner{
min-width:0;
display:flex;
flex-direction:column;
gap:3px;
}
.pf-learner code{
max-width:240px;
.pf-recent__learner b{
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
overflow-wrap:anywhere;
}
.pf-recent__learner code{
max-width:100%;
color:var(--text-muted);
font-size:11px;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.pf-recent__cell .vg-badge{
justify-self:start;
}
@media (max-width:1100px){
.pf-signal-strip,
.pf-workspace{
grid-template-columns:1fr;
}
.pf-triage{
grid-template-columns:auto minmax(0,max-content) auto;
justify-content:start;
}
.pf-kpis{
grid-template-columns:repeat(4,minmax(0,1fr));
}
.pf-kpi:nth-child(even),
.pf-kpi + .pf-kpi{border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:0;}
.pf-list,
.pf-personas{
max-height:360px;
}
.pf-recent-list{
max-height:460px;
}
}
@media (max-width:860px){
.pf-triage{
@ -536,34 +850,76 @@ const PF_CSS = `
grid-column:1 / -1;
width:100%;
}
.pf-triage__meta span,
.pf-triage__meta time{
min-width:0;
flex:1;
text-align:left;
}
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
.pf-kpi:nth-child(odd){border-left:0;}
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.pf-list{max-height:380px;}
.pf-tablewrap{max-height:420px;}
.pf-session{grid-template-columns:1fr;}
.pf-session__meta{justify-content:space-between;white-space:normal;}
.pf-recent-list{
display:flex;
flex-direction:column;
gap:10px;
padding:10px;
background:var(--bg-surface-2);
overflow-x:hidden;
}
.pf-recent-head{
display:none;
}
.pf-recent-row{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:10px 12px;
padding:12px;
border:1px solid var(--paper-2);
border-radius:var(--radius);
background:var(--bg-surface);
}
.pf-recent__learner{
grid-column:1 / -1;
padding-bottom:10px;
border-bottom:1px solid var(--paper-2);
}
.pf-recent__learner code{
white-space:normal;
overflow-wrap:anywhere;
}
.pf-recent__cell{
display:grid;
grid-template-columns:minmax(74px,.4fr) minmax(0,1fr);
gap:8px;
align-items:center;
}
.pf-recent__cell::before{
display:block;
content:attr(data-label);
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
line-height:1.35;
}
.pf-session__meta{justify-content:flex-start;}
}
@media (max-width:520px){
.pf-kpis{grid-template-columns:1fr;}
.pf-kpi + .pf-kpi{border-left:0;border-top:1px solid var(--hair);}
.pf-triage__meta{
.pf-kpi,
.pf-kpi:nth-child(even),
.pf-kpi + .pf-kpi{border-left:0;}
.pf-kpi + .pf-kpi{border-top:1px solid var(--hair);}
.pf-persona__actions{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-triage__meta time{
grid-column:1 / -1;
border-left:0;
border-top:1px solid var(--hair);
.pf-persona__actions .vg-btn{
width:100%;
}
.pf-triage__meta span:nth-child(2){
border-left:1px solid var(--hair);
.pf-recent-list{
padding:8px;
}
.pf-recent-row{
grid-template-columns:1fr;
gap:10px;
}
.pf-recent__cell{
grid-template-columns:minmax(64px,.32fr) minmax(0,1fr);
}
}
`;

View file

@ -18,7 +18,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { AppShell } from "../components/shell/AppShell";
import { ClientAvatar } from "../components/avatar/ClientAvatar";
import {
AVATAR_EXPRESSION_LIBRARY,
ClientAvatar,
expressionLabelFor,
} from "../components/avatar/ClientAvatar";
import type { AvatarState, AvatarAffect, AvatarPersona } from "../components/avatar/ClientAvatar";
import { Kicker, Button, Icon } from "../components/ui";
import {
@ -99,6 +103,50 @@ const DIFFICULTY_LABEL: Record<string, string> = {
hard: "고난도",
};
const PERSONA_AVATAR_LOOKS = {
P1: {
skinTone: "#F0DDC4",
hair: { style: "long-straight", color: "#5A4030" },
outfitColor: "#7C8A92",
eyeColor: "#7A5A3C",
accentColor: "#6B5E7D",
rasterArtSet: "seoyeon",
expressionBias: "sad",
},
P4: {
skinTone: "#E5C0A0",
hair: { style: "long-straight", color: "#35261F" },
outfitColor: "#5F6F88",
eyeColor: "#2F3438",
accentColor: "#3B6E8F",
expressionBias: "anxious",
},
P5: {
skinTone: "#D7AD8D",
hair: { style: "messy", color: "#28231F" },
outfitColor: "#6F766F",
eyeColor: "#24292A",
accentColor: "#7D8784",
expressionBias: "guarded",
},
P6: {
skinTone: "#E6BE9A",
hair: { style: "soft-wave", color: "#2D241F" },
outfitColor: "#776F85",
eyeColor: "#2E2B35",
accentColor: "#6B5E7D",
expressionBias: "conflicted",
},
P7: {
skinTone: "#D5AA88",
hair: { style: "side-part", color: "#23211E" },
outfitColor: "#596367",
eyeColor: "#25292B",
accentColor: "#6F7777",
expressionBias: "tired",
},
} satisfies Record<string, Partial<AvatarPersona>>;
let _uid = 100;
const nextId = () => ++_uid;
@ -194,6 +242,8 @@ function buildPersonaUi(
}
const name = shortName(summary.display_name);
const codeKey = summary.code.toUpperCase() as keyof typeof PERSONA_AVATAR_LOOKS;
const look = PERSONA_AVATAR_LOOKS[codeKey] ?? {};
const sex = demographicText(summary.demographics.sex);
const isMale = sex === "male" || sex === "남성";
const ageBand = personaAgeBand(summary);
@ -207,15 +257,21 @@ function buildPersonaUi(
return {
avatar: {
code: summary.code,
label: `${name} · ${personaMeta(summary)}`,
ageBand,
skinTone: isMale ? "#D8B18F" : "#E3BE9B",
hair: {
skinTone: look.skinTone ?? (isMale ? "#D8B18F" : "#E3BE9B"),
hair: look.hair ?? {
style: ageBand === "teen" ? "long-straight" : isMale ? "short" : "bob",
color: isMale ? "#25221F" : "#31251E",
},
outfitColor: look.outfitColor ?? (isMale ? "#5E676A" : "#6A6874"),
eyeColor: look.eyeColor ?? "#2B2B2B",
accentColor: look.accentColor,
rasterArtSet: (look as Partial<AvatarPersona>).rasterArtSet,
realism: 0.4,
resistance: summary.difficulty === "hard" ? 0.68 : summary.difficulty === "moderate" ? 0.45 : 0.3,
expressionBias: look.expressionBias,
},
context: {
initial: name.slice(0, 1) || summary.code.slice(0, 1),
@ -256,6 +312,63 @@ function metersFromOpenness(openness: number) {
};
}
function baselineExpressionFor(summary: PersonaSummary | null): AvatarAffect {
if (!summary) return "neutral";
const codeKey = summary.code.toUpperCase() as keyof typeof PERSONA_AVATAR_LOOKS;
return PERSONA_AVATAR_LOOKS[codeKey]?.expressionBias ?? "neutral";
}
function expressionForSession({
state,
openness,
paused,
safety,
summary,
stage,
}: {
state: AvatarState;
openness: number;
paused: boolean;
safety: string | null;
summary: PersonaSummary | null;
stage: SessionStage;
}): AvatarAffect {
const o = clamp01(openness);
const code = summary?.code.toUpperCase() ?? "";
const baseline = baselineExpressionFor(summary);
if (safety) return "startled";
if (paused) return "tired";
if (state === "thinking") {
if (code === "P4" && o < 0.38) return "anxious";
if (code === "P6") return o < 0.55 ? "conflicted" : "determined";
if (code === "P7") return o < 0.45 ? "overwhelmed" : "tired";
if (o < 0.32) return "guarded";
return "confused";
}
if (state === "listening") {
if (o < 0.28) return code === "P4" ? "panic" : "guarded";
if (o < 0.48) return baseline === "tired" ? "bored" : baseline;
if (o > 0.72) return "warm";
return "calm";
}
if (state === "speaking") {
if (o < 0.22) return code === "P7" ? "bored" : "resistant";
if (o < 0.38) return code === "P5" ? "skeptical" : baseline;
if (stage === "정리") return "relief";
if (o > 0.78) return "hopeful";
if (o > 0.62) return "warm";
return "neutral";
}
if (o < 0.3) return baseline;
if (o > 0.76) return "relief";
return "neutral";
}
/* ===================================================================== */
export default function Session() {
@ -276,6 +389,7 @@ export default function Session() {
[personaSummary, personaCode],
);
const clientName = personaUi.context.name;
const primaryContext = personaUi.context.rows[0] ?? null;
// ── 세션 진행 상태 ──
const [started, setStarted] = useState(false);
@ -302,6 +416,8 @@ export default function Session() {
const [voiceDetail, setVoiceDetail] = useState(
"마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다.",
);
// 음성 캐스케이드 가용성: null=미확인, true=가능, false=provider 키 미설정(버튼 사전 비활성).
const [voiceAvailable, setVoiceAvailable] = useState<boolean | null>(null);
const [resumedSessionLoaded, setResumedSessionLoaded] = useState(false);
const [voiceAnalyser, setVoiceAnalyser] = useState<AnalyserNode | null>(null);
@ -482,7 +598,14 @@ export default function Session() {
setStartError(null);
setResumedSessionLoaded(false);
try {
const res = await sessionApi.start(personaCode, "humanistic");
// 회기 이론모드 = 페르소나 설계 이론(theory_target) 기준. CBT 페르소나는 cbt로 시작.
// (이전엔 'humanistic' 하드코딩 — 평가 이론부합이 항상 인간중심으로 고정됐다.)
const theoryMode =
personaSummary.theory_target.find(
(t): t is "humanistic" | "cbt" | "integrative" =>
t === "humanistic" || t === "cbt" || t === "integrative",
) ?? "humanistic";
const res = await sessionApi.start(personaCode, theoryMode);
setLiveSessionId(res.session_id); // ★ 진짜 세션 id 저장 — turn 이 이걸 써야 라이브
setStage(res.stage);
setOpenness(res.effective_openness);
@ -496,6 +619,17 @@ export default function Session() {
setMicOn(false);
setVoiceStatus("idle");
setVoiceDetail("마이크를 켜면 권한 요청 후 음성으로 회기를 진행합니다.");
// 음성 가용성 사전 확인 — provider 키 미설정이면 마이크 버튼을 비활성화하고 명확히 안내한다.
// (클릭→권한요청→마이크 깜빡→WS degraded 종료의 혼란스러운 "바로 꺼짐"을 방지.)
void sessionApi.voiceHealth().then(({ available }) => {
setVoiceAvailable(available);
if (!available) {
setVoiceStatus("degraded");
setVoiceDetail(
"음성 기능이 설정되지 않았습니다(STT/TTS provider 키 필요). 아래 텍스트 입력으로 회기를 진행하세요.",
);
}
});
} catch {
setStartError("세션을 열지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
} finally {
@ -933,6 +1067,10 @@ export default function Session() {
const toggleMic = useCallback(() => {
if (paused || sending) return;
if (voiceAvailable === false) {
pushSignal("warn", "음성 미설정 — 텍스트로 진행");
return;
}
if (
voiceStatus === "requesting" ||
voiceStatus === "connecting" ||
@ -946,7 +1084,7 @@ export default function Session() {
} else {
void startVoiceCapture();
}
}, [finishVoiceUtterance, micOn, paused, sending, startVoiceCapture, voiceStatus]);
}, [finishVoiceUtterance, micOn, paused, sending, startVoiceCapture, voiceAvailable, voiceStatus, pushSignal]);
const togglePause = useCallback(() => {
setPaused((p) => {
@ -1003,12 +1141,22 @@ export default function Session() {
}
}, [liveSessionId, navigate, pushSignal]);
// 아바타 affect: 라포(openness)가 낮으면 저항/우울, 높아지면 중립으로
const avatarAffect: AvatarAffect = useMemo(() => {
if (openness < 0.35) return "resistant";
if (openness < 0.6) return "depressed";
return "neutral";
}, [openness]);
const meters = metersFromOpenness(openness);
const avatarAffect: AvatarAffect = useMemo(
() =>
expressionForSession({
state: avatarState,
openness,
paused,
safety,
summary: personaSummary,
stage,
}),
[avatarState, openness, paused, personaSummary, safety, stage],
);
const avatarExpressionLabel = expressionLabelFor(avatarAffect);
const avatarExpressionCount = AVATAR_EXPRESSION_LIBRARY.length;
// 음성 오브 상태(아바타 state → 오브 data-orb)
const orbState = paused
@ -1021,7 +1169,6 @@ export default function Session() {
? "thinking"
: "idle";
const meters = metersFromOpenness(openness);
const micBusy =
voiceStatus === "requesting" ||
voiceStatus === "connecting" ||
@ -1105,6 +1252,7 @@ export default function Session() {
(started ? "sx-page--active" : "sx-page--prestart") +
(started ? ` sx-feedback-${feedbackMode}` : "")
}
data-avatar-expressions={avatarExpressionCount}
>
{/* ── 페이지 헤드라인 + 가로 단계 미니 ── */}
<header className="sx-head">
@ -1150,7 +1298,7 @@ export default function Session() {
<ClientAvatar
persona={personaUi.avatar}
state="idle"
affect="anxious"
affect={baselineExpressionFor(personaSummary)}
analyser={null}
rapport={0}
size={200}
@ -1236,6 +1384,18 @@ export default function Session() {
<small></small>
</span>
</div>
<div className="sx-mobile-context__brief">
<span>
<b>{currentPhase.desc}</b>
<small>{stage}</small>
</span>
{primaryContext ? (
<span>
<b>{primaryContext.v}</b>
<small>{primaryContext.l}</small>
</span>
) : null}
</div>
<div className="sx-mobile-context__row sx-mobile-context__row--meters">
<span>
<b>{meters.resistance > 0.55 ? "방어 신호" : "완화 신호"}</b>
@ -1367,7 +1527,9 @@ export default function Session() {
/>
</div>
<div className="sx-stage__now">: {stageStateText[avatarState]}</div>
<div className="sx-stage__now">
: {stageStateText[avatarState]} · {avatarExpressionLabel}
</div>
</section>
{/* 실시간 자막(대본 스타일) + 텍스트 입력 폴백 */}
@ -1494,7 +1656,11 @@ export default function Session() {
<span className="sx-signal__one-when"></span>
)}
</div>
) : null}
) : (
<p className="sx-signal__rest">
. .
</p>
)}
<div className="sx-signal__seq">
<span className="sx-signal__seq-label"> </span>
@ -1595,7 +1761,7 @@ export default function Session() {
type="button"
className={"sx-mic " + (micOn ? "is-on" : "is-off")}
onClick={toggleMic}
disabled={paused || micBusy}
disabled={paused || micBusy || voiceAvailable === false}
aria-pressed={micOn}
aria-label={micOn ? "발화 보내기" : "마이크 켜기"}
>

View file

@ -276,7 +276,7 @@ export default function SessionReview() {
</div>
<div className="sr-actions">
<Button
variant="secondary"
variant="primary"
size="sm"
leading={<Icon name="play" size={14} />}
disabled={!canOpenAudio}
@ -287,7 +287,7 @@ export default function SessionReview() {
</Button>
<Button
variant="ghost"
variant="secondary"
size="sm"
leading={<Icon name="review" size={14} />}
disabled={!canExportPdf}
@ -432,7 +432,7 @@ export default function SessionReview() {
</div>
<div className="sr-right">
<Card className="sr-card sr-card--side">
<Card className="sr-card sr-card--side sr-card--rubric">
<Kicker> </Kicker>
<div className="sr-rubric" style={{ marginTop: "var(--sp-4)" }}>
{data.rubric.length > 0 ? (
@ -469,7 +469,7 @@ export default function SessionReview() {
</div>
</Card>
<Card className="sr-card sr-card--side">
<Card className="sr-card sr-card--side sr-card--good">
<Kicker> </Kicker>
<div className="sr-points sr-points--good" style={{ marginTop: "var(--sp-4)" }}>
{data.goodMoments.length > 0 ? (
@ -490,7 +490,7 @@ export default function SessionReview() {
</div>
</Card>
<Card className="sr-card sr-card--side">
<Card className="sr-card sr-card--side sr-card--growth">
<Kicker> </Kicker>
<div className="sr-points sr-points--grow" style={{ marginTop: "var(--sp-4)" }}>
{data.growthPoints.length > 0 ? (
@ -517,7 +517,7 @@ export default function SessionReview() {
) : null}
</Card>
<div className="sr-feedback">
<div className={`sr-feedback${data.clientFeedback ? " sr-feedback--filled" : ""}`}>
<div className="sr-feedback__kicker">
<span className="sr-technique__dot" aria-hidden="true" />

View file

@ -80,11 +80,60 @@ function SettingsLoadingState({
);
}
/**
* (/ )
* · . testId·role .
*/
function SettingsSkeleton({
children,
testId,
variant = "lines",
}: {
children: string;
testId: string;
variant?: "lines" | "rows" | "voice";
}) {
return (
<div
className={`vg-set__skel vg-set__skel--${variant}`}
role="status"
aria-live="polite"
data-testid={testId}
>
<span className="vg-set__skel-sr">{children}</span>
{variant === "rows" ? (
<>
<span className="vg-set__skel-row" aria-hidden="true">
<span className="vg-set__skel-bar" />
<span className="vg-set__skel-pill" />
</span>
<span className="vg-set__skel-row" aria-hidden="true">
<span className="vg-set__skel-bar" />
<span className="vg-set__skel-pill" />
</span>
</>
) : variant === "voice" ? (
<>
<span className="vg-set__skel-card" aria-hidden="true" />
<span className="vg-set__skel-card" aria-hidden="true" />
</>
) : (
<>
<span className="vg-set__skel-line vg-set__skel-line--label" aria-hidden="true" />
<span className="vg-set__skel-line" aria-hidden="true" />
<span className="vg-set__skel-line vg-set__skel-line--short" aria-hidden="true" />
</>
)}
</div>
);
}
export default function Settings() {
const { user } = useAuth();
const role = user?.role ?? "learner";
const isAdmin = role === "admin";
const [dark, setDark] = useTheme();
const [activeSection, setActiveSection] = useState("account");
const [profile, setProfile] = useState<UserProfileResponse | null>(null);
const [displayName, setDisplayName] = useState(user?.name ?? "");
@ -169,8 +218,8 @@ export default function Settings() {
const base: NavItem[] = [
{ id: "account", label: "계정", icon: "users" },
{ id: "appearance", label: "테마", icon: "settings" },
{ id: "voice", label: "음성", icon: "mic" },
{ id: "notify", label: "알림", icon: "info" },
{ id: "voice", label: "음성", icon: "mic" },
];
if (isAdmin) {
base.splice(1, 0, { id: "engine", label: "AI 운영", icon: "shield", adminOnly: true });
@ -178,6 +227,30 @@ export default function Settings() {
return base;
}, [isAdmin]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (visible?.target.id) {
setActiveSection(visible.target.id.replace(/^set-/, ""));
}
},
{
rootMargin: "-18% 0px -62% 0px",
threshold: [0.2, 0.55],
},
);
for (const item of navItems) {
const section = document.getElementById(`set-${item.id}`);
if (section) observer.observe(section);
}
return () => observer.disconnect();
}, [navItems]);
const visibleNotifications = useMemo(
() => [
{
@ -206,6 +279,7 @@ export default function Settings() {
);
const goSection = (id: string) => {
setActiveSection(id);
document.getElementById(`set-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
};
@ -274,7 +348,9 @@ export default function Settings() {
}) : cur);
};
const initials = (displayName || profile?.email || "U").trim().slice(0, 1).toUpperCase();
const accountName = displayName || profile?.email || user?.name || user?.email || "사용자";
const accountEmail = profile?.email ?? user?.email ?? "";
const initials = accountName.trim().slice(0, 1).toUpperCase();
const engineHasRequiredFields = engineConfig
? engineConfig.engine_url.trim().length > 0 && engineConfig.model.trim().length > 0
: false;
@ -290,12 +366,20 @@ export default function Settings() {
return (
<AppShell contextLabel="설정">
<div style={{ marginBottom: "var(--sp-6)" }}>
<div className="vg-set__mast">
<SectionHead
kicker="설정"
title="계정과 학습 환경을 관리합니다"
desc="계정 표시 정보, 음성, 알림, 운영 설정을 한 곳에서 관리합니다."
/>
<div className="vg-set__mast-meta" aria-label="설정 상태">
<span className="vg-set__pill">{roleLabel(role)}</span>
{isAdmin ? (
<span className={`vg-set__pill vg-set__pill--${engineServiceStatus}`}>
AI {healthStatusLabel(engineService?.status)}
</span>
) : null}
</div>
</div>
{error ? (
@ -308,26 +392,47 @@ export default function Settings() {
) : null}
<div className="vg-set" aria-busy={loading}>
<nav className="vg-set__nav" aria-label="설정 섹션">
<div className="vg-set__nav-kicker">SETTINGS</div>
{navItems.map((item) => (
<button
key={item.id}
type="button"
className="vg-set__nav-item"
onClick={() => goSection(item.id)}
>
<Icon name={item.icon} size={18} />
{item.label}
</button>
))}
</nav>
<aside className="vg-set__rail">
<div className="vg-set__rail-card" aria-label="계정 요약">
<div className="vg-set__rail-avatar" aria-hidden="true">
{initials}
</div>
<div className="vg-set__rail-copy">
<div className="vg-set__rail-name">{accountName}</div>
<div className="vg-set__rail-email">{accountEmail}</div>
<div className="vg-set__rail-badges">
<Badge tone="accent">{roleLabel(role)}</Badge>
{affiliation ? <Badge tone="neutral">{affiliation}</Badge> : null}
</div>
</div>
</div>
<nav className="vg-set__nav" aria-label="설정 섹션">
<div className="vg-set__nav-kicker">SECTIONS</div>
{navItems.map((item) => (
<button
key={item.id}
type="button"
className={
"vg-set__nav-item" + (activeSection === item.id ? " is-active" : "")
}
aria-current={activeSection === item.id ? "location" : undefined}
onClick={() => goSection(item.id)}
>
<Icon name={item.icon} size={17} />
<span>{item.label}</span>
</button>
))}
</nav>
</aside>
<div className="vg-set__forms">
<Panel id="set-account" className="vg-set__group">
<Panel id="set-account" className="vg-set__group vg-set__group--wide">
<div className="vg-set__group-head">
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
<div>
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
</div>
</div>
<div className="vg-set__profile">
@ -335,8 +440,8 @@ export default function Settings() {
{initials}
</div>
<div className="vg-set__profile-meta">
<div className="n">{displayName || profile?.email}</div>
<div className="e">{profile?.email ?? user?.email}</div>
<div className="n">{accountName}</div>
<div className="e">{accountEmail}</div>
<div className="badges">
<Badge tone="accent">{roleLabel(role)}</Badge>
{affiliation ? <Badge tone="neutral">{affiliation}</Badge> : null}
@ -344,12 +449,12 @@ export default function Settings() {
</div>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<div className="vg-set__row-field">
<div className="vg-set__field-grid vg-set__field-grid--account">
<div className="vg-set__field">
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<Field>
<Input
ref={displayNameInputRef}
@ -359,30 +464,32 @@ export default function Settings() {
displayNameValueRef.current = event.target.value;
setDisplayName(event.target.value);
}}
placeholder="표시할 이름을 입력하세요"
aria-label="표시 이름"
/>
</Field>
</div>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"></span>
<span className="h"> .</span>
</div>
<div className="vg-set__row-field">
<div className="vg-set__field">
<div className="vg-set__field-copy">
<span className="l"></span>
<span className="h"> .</span>
</div>
<Field>
<Input value={profile?.email ?? ""} disabled aria-label="이메일" />
<Input
value={profile?.email ?? ""}
disabled
placeholder="로그인 계정 이메일"
aria-label="이메일"
/>
</Field>
</div>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"></span>
<span className="h"> .</span>
</div>
<div className="vg-set__row-field">
<div className="vg-set__field vg-set__field--wide">
<div className="vg-set__field-copy">
<span className="l"></span>
<span className="h"> .</span>
</div>
<Field>
<Input
ref={affiliationInputRef}
@ -392,6 +499,7 @@ export default function Settings() {
affiliationValueRef.current = event.target.value;
setAffiliation(event.target.value);
}}
placeholder="소속을 입력하세요"
aria-label="소속"
/>
</Field>
@ -405,43 +513,53 @@ export default function Settings() {
</span>
) : null}
<Button onClick={() => void saveProfile()} disabled={loading || !profileReady}>
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void saveProfile()}
disabled={loading || !profileReady}
>
</Button>
</div>
</Panel>
{isAdmin ? (
<Panel id="set-engine" className="vg-set__group">
<div className="vg-set__group-head">
<Kicker></Kicker>
<div className="vg-set__group-title" style={{ marginTop: 6 }}>
AI
</div>
<div className="vg-set__group-desc">
.
</div>
</div>
<div className={`vg-set__ops vg-set__ops--${engineServiceStatus}`} role="status">
<span className="vg-set__ops-dot" aria-hidden="true" />
<Panel
id="set-engine"
className="vg-set__group vg-set__group--wide vg-set__group--engine"
>
<div className="vg-set__group-head vg-set__group-head--split">
<div>
<b> {healthStatusLabel(engineService?.status)}</b>
<span>
{engineService
? `${engineService.detail} · ${engineService.metric}`
: "운영 상태를 확인하는 중입니다."}
</span>
<Kicker></Kicker>
<div className="vg-set__group-title vg-set__group-title--kicker">
AI
</div>
<div className="vg-set__group-desc">
.
</div>
</div>
<div className={`vg-set__ops vg-set__ops--${engineServiceStatus}`} role="status">
<span className="vg-set__ops-dot" aria-hidden="true" />
<div>
<b> {healthStatusLabel(engineService?.status)}</b>
<span>
{engineService
? `${engineService.detail} · ${engineService.metric}`
: "운영 상태를 확인하는 중입니다."}
</span>
</div>
</div>
</div>
{engineConfig ? (
<>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<div className="vg-set__row-field">
<div className="vg-set__engine-grid">
<div className="vg-set__control-block">
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<div className="vg-set__seg" role="radiogroup" aria-label="AI 운영 방식">
{ENGINE_MODES.map((mode) => (
<button
@ -461,46 +579,46 @@ export default function Settings() {
))}
</div>
</div>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<div className="vg-set__row-field">
<Field>
<Input
ref={engineUrlInputRef}
value={engineConfig.engine_url}
onChange={(event) =>
updateEngineConfig({ engine_url: event.target.value })
}
placeholder="운영 연결 주소를 입력하세요"
aria-label="AI 연결 주소"
/>
</Field>
</div>
</div>
<div className="vg-set__field-grid vg-set__field-grid--engine">
<div className="vg-set__field">
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<Field>
<Input
ref={engineUrlInputRef}
value={engineConfig.engine_url}
onChange={(event) =>
updateEngineConfig({ engine_url: event.target.value })
}
placeholder="운영 연결 주소를 입력하세요"
aria-label="AI 연결 주소"
/>
</Field>
</div>
<div className="vg-set__row">
<div className="vg-set__row-label">
<span className="l"></span>
<span className="h"> .</span>
</div>
<div className="vg-set__row-field">
<Field>
<Input
ref={engineModelInputRef}
value={engineConfig.model}
onChange={(event) => updateEngineConfig({ model: event.target.value })}
placeholder="기본 모델 이름을 입력하세요"
aria-label="모델"
/>
</Field>
<div className="vg-set__meta">
<span> : {engineConfig.updated_by ?? "-"}</span>
<span> : {engineStorageLabel}</span>
<div className="vg-set__field">
<div className="vg-set__field-copy">
<span className="l"></span>
<span className="h"> .</span>
</div>
<Field>
<Input
ref={engineModelInputRef}
value={engineConfig.model}
onChange={(event) =>
updateEngineConfig({ model: event.target.value })
}
placeholder="기본 모델 이름을 입력하세요"
aria-label="모델"
/>
</Field>
<div className="vg-set__meta">
<span> : {engineConfig.updated_by ?? "-"}</span>
<span> : {engineStorageLabel}</span>
</div>
</div>
</div>
</div>
@ -513,6 +631,8 @@ export default function Settings() {
</span>
) : null}
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void saveEngine()}
disabled={loading || !engineHasRequiredFields}
>
@ -521,17 +641,19 @@ export default function Settings() {
</div>
</>
) : (
<SettingsLoadingState testId="settings-engine-loading">
<SettingsSkeleton testId="settings-engine-loading" variant="lines">
AI .
</SettingsLoadingState>
</SettingsSkeleton>
)}
</Panel>
) : null}
<Panel id="set-appearance" className="vg-set__group">
<Panel id="set-appearance" className="vg-set__group vg-set__group--compact">
<div className="vg-set__group-head">
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
<div>
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
</div>
</div>
{preferences ? (
<>
@ -540,13 +662,7 @@ export default function Settings() {
<div className="l"> </div>
<div className="h"> .</div>
</div>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "var(--sp-3)",
}}
>
<span className="vg-set__toggle-line">
<Icon name={dark ? "moon" : "sun"} size={16} />
<Toggle checked={dark} onChange={setDark} label="다크 모드" />
</span>
@ -559,6 +675,8 @@ export default function Settings() {
</span>
) : null}
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void savePreferences("appearance")}
disabled={loading || !preferencesReady}
>
@ -567,16 +685,18 @@ export default function Settings() {
</div>
</>
) : (
<SettingsLoadingState testId="settings-preferences-loading">
<SettingsSkeleton testId="settings-preferences-loading" variant="rows">
.
</SettingsLoadingState>
</SettingsSkeleton>
)}
</Panel>
<Panel id="set-voice" className="vg-set__group">
<Panel id="set-voice" className="vg-set__group vg-set__group--wide">
<div className="vg-set__group-head">
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
<div>
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
</div>
</div>
{preferences ? (
@ -619,12 +739,12 @@ export default function Settings() {
})}
</div>
<div className="vg-set__row" style={{ marginTop: "var(--sp-5)" }}>
<div className="vg-set__row-label">
<div className="vg-set__range-row">
<div className="vg-set__field-copy">
<span className="l"> </span>
<span className="h"> .</span>
</div>
<div className="vg-set__row-field">
<div className="vg-set__range-control">
<input
type="range"
min={0.8}
@ -642,9 +762,8 @@ export default function Settings() {
)
}
aria-label="말하기 속도"
style={{ accentColor: "var(--accent)", width: "100%" }}
/>
<div className="vg-set__meta">
<div className="vg-set__meta vg-set__meta--range">
<span>0.8x</span>
<span>{preferences.voice_rate.toFixed(2)}x</span>
<span>1.2x</span>
@ -660,6 +779,8 @@ export default function Settings() {
</span>
) : null}
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void savePreferences("voice")}
disabled={loading || !preferencesReady}
>
@ -673,37 +794,41 @@ export default function Settings() {
</SettingsLoadingState>
)
) : (
<SettingsLoadingState testId="settings-voice-loading">
<SettingsSkeleton testId="settings-voice-loading" variant="voice">
.
</SettingsLoadingState>
</SettingsSkeleton>
)}
</Panel>
<Panel id="set-notify" className="vg-set__group">
<Panel id="set-notify" className="vg-set__group vg-set__group--compact">
<div className="vg-set__group-head">
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
<div>
<div className="vg-set__group-title"></div>
<div className="vg-set__group-desc"> .</div>
</div>
</div>
{preferences ? (
<>
{visibleNotifications.map((item) => (
<div className="vg-set__opt" key={item.id}>
<div className="vg-set__opt-text">
<div className="l">{item.label}</div>
<div className="h">{item.hint}</div>
<div className="vg-set__opt-grid">
{visibleNotifications.map((item) => (
<div className="vg-set__opt" key={item.id}>
<div className="vg-set__opt-text">
<div className="l">{item.label}</div>
<div className="h">{item.hint}</div>
</div>
<Toggle
checked={Boolean(
preferences.notifications[item.id as keyof NotificationPreferences],
)}
onChange={(next) =>
updateNotification(item.id as keyof NotificationPreferences, next)
}
label={item.label}
/>
</div>
<Toggle
checked={Boolean(
preferences.notifications[item.id as keyof NotificationPreferences],
)}
onChange={(next) =>
updateNotification(item.id as keyof NotificationPreferences, next)
}
label={item.label}
/>
</div>
))}
))}
</div>
<div className="vg-set__foot">
{savedKey === "notify" ? (
@ -713,6 +838,8 @@ export default function Settings() {
</span>
) : null}
<Button
size="sm"
leading={<Icon name="check" size={14} />}
onClick={() => void savePreferences("notify")}
disabled={loading || !preferencesReady}
>
@ -721,9 +848,9 @@ export default function Settings() {
</div>
</>
) : (
<SettingsLoadingState testId="settings-notify-loading">
<SettingsSkeleton testId="settings-notify-loading" variant="rows">
.
</SettingsLoadingState>
</SettingsSkeleton>
)}
</Panel>
</div>

View file

@ -0,0 +1,239 @@
.axl {
width: min(100%, 1440px);
margin: 0 auto;
padding: var(--sp-7) var(--sp-6) var(--sp-8);
display: flex;
flex-direction: column;
gap: var(--sp-7);
}
.axl__head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-5);
align-items: end;
padding-bottom: var(--sp-5);
border-bottom: 1px solid var(--line-subtle);
}
.axl__head h1,
.axl__persona-title h2 {
margin: 0;
color: var(--text-strong);
letter-spacing: 0;
}
.axl__head h1 {
font-size: clamp(28px, 4vw, 38px);
line-height: 1.05;
}
.axl__head p {
max-width: 720px;
margin: var(--sp-2) 0 0;
color: var(--text-muted);
font-size: var(--fs-sm);
line-height: 1.6;
}
.axl__stats {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
justify-content: flex-end;
}
.axl__stats span {
min-width: 104px;
padding: 10px 12px;
border: 1px solid var(--line-subtle);
border-radius: 8px;
background: var(--surface);
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: 700;
text-transform: uppercase;
}
.axl__stats b {
display: block;
color: var(--text-strong);
font-family: var(--font-num);
font-size: 22px;
line-height: 1.1;
}
.axl__stack {
display: flex;
flex-direction: column;
gap: var(--sp-8);
}
.axl__persona {
display: flex;
flex-direction: column;
gap: var(--sp-5);
padding-bottom: var(--sp-7);
border-bottom: 1px solid var(--line-subtle);
}
.axl__persona-head {
display: grid;
grid-template-columns: 156px minmax(0, 1fr);
gap: var(--sp-5);
align-items: center;
}
.axl__persona-head .vg-avatar {
justify-self: start;
}
.axl__persona-title {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.axl__persona-title h2 {
font-size: 24px;
line-height: 1.2;
overflow-wrap: anywhere;
}
.axl__persona-title dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-3);
margin: 0;
}
.axl__persona-title dl > div {
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--line-subtle);
border-radius: 8px;
background: color-mix(in srgb, var(--surface) 82%, transparent);
}
.axl__persona-title dt {
margin: 0 0 4px;
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: 800;
text-transform: uppercase;
}
.axl__persona-title dd {
margin: 0;
color: var(--text-body);
font-family: var(--font-num);
font-size: var(--fs-xs);
overflow-wrap: anywhere;
}
.axl__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(136px, 1fr));
gap: 12px;
}
.axl__card {
--tone: #53616f;
min-width: 0;
min-height: 188px;
position: relative;
display: grid;
justify-items: center;
align-content: start;
gap: 8px;
padding: 10px;
border: 1px solid var(--line-subtle);
border-radius: 8px;
background: var(--surface);
overflow: hidden;
}
.axl__card::before {
content: "";
position: absolute;
inset: 0 0 auto;
height: 3px;
background: var(--tone);
}
.axl__card--positive {
--tone: #2f7d57;
}
.axl__card--negative {
--tone: #a84444;
}
.axl__card--defensive {
--tone: #7a5730;
}
.axl__card--cognitive {
--tone: #386f9d;
}
.axl__card--energy {
--tone: #7565a8;
}
.axl__card .vg-avatar {
flex: none;
}
.axl__card .vg-avatar__stage {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
}
.axl__card-meta {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
text-align: center;
}
.axl__card-meta b {
color: var(--text-strong);
font-size: var(--fs-sm);
line-height: 1.2;
overflow-wrap: anywhere;
}
.axl__card-meta span,
.axl__card-meta small {
color: var(--text-muted);
font-family: var(--font-num);
font-size: 11px;
line-height: 1.25;
overflow-wrap: anywhere;
}
@media (max-width: 820px) {
.axl {
padding: var(--sp-5) var(--sp-4) var(--sp-7);
}
.axl__head,
.axl__persona-head {
grid-template-columns: 1fr;
}
.axl__stats {
justify-content: flex-start;
}
.axl__persona-title dl {
grid-template-columns: 1fr;
}
.axl__grid {
grid-template-columns: repeat(auto-fill, minmax(124px, 1fr));
}
}

View file

@ -0,0 +1,8 @@
.ap{padding:32px;max-width:1100px;margin:0 auto;color:var(--text-body);}
.ap__head h1{font-size:var(--fs-xl);margin:0 0 6px;}
.ap__head p{color:var(--text-muted);font-size:var(--fs-sm);margin:0 0 28px;}
.ap__hero{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;padding:24px;border-radius:20px;background:var(--bg-stage);margin-bottom:32px;}
.ap__grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:18px;}
.ap__cell{margin:0;display:flex;flex-direction:column;align-items:center;gap:8px;}
.ap__cell figcaption{font-size:var(--fs-xs);color:var(--text-muted);}
.ap__count{margin-top:28px;color:var(--text-muted);font-size:var(--fs-sm);}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -243,6 +243,7 @@
/* ── 세그먼트 토글(엔진 모드): 백엔드 지원 모드, 둥근 알약 아님(8px) ── */
.vg-set__seg {
width: min(100%, 520px);
max-width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
background: var(--bg-surface-2);
@ -250,6 +251,7 @@
border-radius: var(--radius);
padding: 3px;
gap: 3px;
min-width: 0;
}
.vg-set__seg-btn {
display: inline-flex;
@ -257,16 +259,20 @@
justify-content: center;
gap: 8px;
min-width: 0;
max-width: 100%;
font-family: var(--font-sans);
font-size: var(--fs-sm);
font-weight: 500;
line-height: 1.35;
color: var(--text-body);
background: transparent;
border: none;
border-radius: var(--radius-sm);
padding: 8px 16px;
padding: 8px clamp(10px, 2.5vw, 16px);
cursor: pointer;
white-space: nowrap;
white-space: normal;
overflow-wrap: anywhere;
text-align: center;
transition: background var(--dur-base) var(--ease-out),
color var(--dur-base) var(--ease-out);
}
@ -338,6 +344,82 @@
background: var(--neutral-sig);
}
/* ── 로딩 스켈레톤 — 섹션 형태 윤곽(회색 박스 반복 방지) ── */
.vg-set__skel {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface);
}
.vg-set__skel-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.vg-set__skel-line,
.vg-set__skel-bar,
.vg-set__skel-pill,
.vg-set__skel-card {
--skel-base: var(--bg-surface-2);
--skel-sheen: color-mix(in srgb, var(--bg-surface-2) 55%, var(--surface));
background: linear-gradient(
100deg,
var(--skel-base) 30%,
var(--skel-sheen) 50%,
var(--skel-base) 70%
);
background-size: 220% 100%;
border-radius: var(--radius-sm);
animation: vg-set-skel-shimmer 1.5s var(--ease-out) infinite;
}
.vg-set__skel-line {
height: 12px;
}
.vg-set__skel-line--label {
width: 38%;
height: 10px;
margin-bottom: 2px;
}
.vg-set__skel-line--short {
width: 62%;
}
.vg-set__skel-row {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.vg-set__skel-bar {
flex: 1;
height: 28px;
}
.vg-set__skel-pill {
flex: none;
width: 42px;
height: 22px;
border-radius: 100px;
}
.vg-set__skel-card {
height: 48px;
}
@keyframes vg-set-skel-shimmer {
from {
background-position: 180% 0;
}
to {
background-position: -80% 0;
}
}
/* ── 라디오형 음성 프리셋 행 ── */
.vg-set__voicelist {
display: flex;
@ -570,6 +652,12 @@
}
}
@media (max-width: 480px) {
.vg-set__seg {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.vg-toggle__track,
.vg-toggle__knob,
@ -579,4 +667,511 @@
.vg-set__select {
transition-duration: 0.01ms !important;
}
.vg-set__skel-line,
.vg-set__skel-bar,
.vg-set__skel-pill,
.vg-set__skel-card {
animation: none !important;
}
}
/* Density refresh: Settings as a practical control surface. */
.vg-set__mast {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-4);
margin: 0 auto var(--sp-5);
max-width: 1080px;
}
.vg-set__mast .vg-sechead {
margin-bottom: 0;
}
.vg-set__mast-meta {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--sp-2);
padding-top: 2px;
}
.vg-set__pill {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 4px 10px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-surface);
color: var(--text-body);
font: 600 var(--fs-xs) / 1.25 var(--font-num);
white-space: nowrap;
}
.vg-set__pill--ok {
color: var(--pos-text);
background: var(--pos-tint);
border-color: transparent;
}
.vg-set__pill--degraded {
color: var(--warn-text);
background: var(--warn-tint);
border-color: transparent;
}
.vg-set__pill--down {
color: var(--crit-text);
background: var(--crit-tint);
border-color: transparent;
}
.vg-set {
grid-template-columns: 200px minmax(0, 1fr);
gap: var(--sp-4);
max-width: 1080px;
margin: 0 auto;
}
.vg-set__rail {
position: sticky;
top: calc(var(--topbar-h) + var(--sp-4));
display: flex;
flex-direction: column;
gap: var(--sp-3);
min-width: 0;
}
.vg-set__rail-card {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface);
}
.vg-set__rail-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--accent);
color: var(--text-on-accent);
font-size: 17px;
font-weight: 700;
}
.vg-set__rail-copy,
.vg-set__profile-meta {
min-width: 0;
}
.vg-set__rail-name {
color: var(--text-strong);
font-size: var(--fs-sm);
font-weight: 700;
line-height: 1.35;
overflow-wrap: anywhere;
}
.vg-set__rail-email {
margin-top: 2px;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.35;
overflow-wrap: anywhere;
}
.vg-set__rail-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: var(--sp-2);
}
.vg-set__nav {
position: static;
gap: 3px;
padding: var(--sp-2);
border-radius: var(--radius);
box-shadow: none;
}
.vg-set__nav-kicker {
padding: 2px 8px var(--sp-2);
}
.vg-set__nav-item {
min-width: 0;
gap: 9px;
padding: 9px 10px;
border-radius: var(--radius-sm);
}
.vg-set__nav-item span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.vg-set__forms {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.76fr);
gap: var(--sp-4);
}
.vg-set__group {
padding: 20px;
border-radius: var(--radius);
box-shadow: none;
}
.vg-set__group--wide {
grid-column: 1 / -1;
}
#set-account {
order: 1;
}
#set-engine {
order: 2;
}
#set-appearance {
order: 3;
}
#set-notify {
order: 4;
}
#set-voice {
order: 5;
}
.vg-set__group-head {
display: flex;
justify-content: space-between;
gap: var(--sp-4);
margin-bottom: var(--sp-4);
}
.vg-set__group-head--split {
align-items: flex-start;
}
.vg-set__group-title {
font-size: 18px;
line-height: 1.3;
}
.vg-set__group-title--kicker {
margin-top: 4px;
}
.vg-set__group-desc {
font-size: var(--fs-xs);
line-height: 1.45;
margin-top: 3px;
}
.vg-set__profile {
gap: var(--sp-3);
margin-bottom: var(--sp-4);
padding: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface-2);
}
.vg-set__avatar {
width: 44px;
height: 44px;
font-size: 18px;
}
.vg-set__profile-meta .n {
font-size: var(--fs-body);
line-height: 1.35;
}
.vg-set__profile-meta .e {
font-size: var(--fs-xs);
line-height: 1.35;
}
.vg-set__profile-meta .badges {
flex-wrap: wrap;
margin-top: 6px;
}
.vg-set__field-grid {
display: grid;
gap: var(--sp-3);
min-width: 0;
}
.vg-set__field-grid--account {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.vg-set__field-grid--engine {
align-content: start;
}
.vg-set__field {
display: flex;
flex-direction: column;
gap: var(--sp-2);
min-width: 0;
padding: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface);
}
.vg-set__field--wide {
grid-column: 1 / -1;
}
.vg-set__field .vg-field,
.vg-set__field .vg-input {
min-width: 0;
}
.vg-set__field-copy {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.vg-set__field-copy .l,
.vg-set__opt-text .l {
color: var(--text-strong);
font-size: var(--fs-sm);
font-weight: 700;
line-height: 1.35;
}
.vg-set__field-copy .h,
.vg-set__opt-text .h {
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.4;
}
.vg-set__field .vg-input,
.vg-set__range-control input[type="range"] {
width: 100%;
}
.vg-set__field .vg-input {
font-size: var(--fs-sm);
padding: 8px 10px;
}
.vg-set__engine-grid {
display: grid;
grid-template-columns: minmax(270px, 0.8fr) minmax(0, 1.2fr);
gap: var(--sp-3);
align-items: stretch;
}
.vg-set__control-block {
display: flex;
flex-direction: column;
gap: var(--sp-2);
min-width: 0;
padding: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface);
}
.vg-set__control-block .vg-set__seg {
flex: 1;
}
.vg-set__seg {
width: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 1fr;
padding: 3px;
}
.vg-set__seg-btn {
min-height: 34px;
padding: 7px 9px;
font-size: var(--fs-xs);
line-height: 1.25;
}
.vg-set__ops {
width: min(360px, 100%);
margin-bottom: 0;
padding: var(--sp-3);
border-radius: var(--radius-sm);
}
.vg-set__opt-grid {
display: grid;
gap: var(--sp-2);
}
.vg-set__opt {
min-width: 0;
padding: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface);
}
.vg-set__toggle-line {
display: inline-flex;
align-items: center;
gap: var(--sp-3);
color: var(--text-body);
}
.vg-set__voicelist {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: var(--sp-2);
}
.vg-set__voice {
min-width: 0;
gap: var(--sp-3);
padding: var(--sp-3);
align-items: flex-start;
}
.vg-set__voice-info .vn,
.vg-set__voice-info .vd {
display: block;
overflow-wrap: anywhere;
}
.vg-set__voice-info .vd {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.vg-set__voice-play {
width: 30px;
height: 30px;
}
.vg-set__range-row {
display: grid;
grid-template-columns: minmax(150px, 190px) minmax(0, 1fr);
gap: var(--sp-3);
align-items: center;
margin-top: var(--sp-3);
padding: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface);
}
.vg-set__range-control {
min-width: 0;
}
.vg-set__range-control input[type="range"] {
accent-color: var(--accent);
display: block;
}
.vg-set__meta {
margin-top: var(--sp-2);
gap: var(--sp-2) var(--sp-4);
}
.vg-set__meta--range {
justify-content: space-between;
}
.vg-set__foot {
margin-top: var(--sp-3);
padding-top: var(--sp-3);
}
.vg-set__saved {
min-width: 0;
}
.vg-set__state {
padding: var(--sp-3);
border-radius: var(--radius-sm);
}
@media (max-width: 1080px) {
.vg-set__mast,
.vg-set {
max-width: none;
}
.vg-set {
grid-template-columns: 1fr;
}
.vg-set__rail {
position: static;
display: grid;
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
align-items: start;
}
.vg-set__forms {
grid-template-columns: minmax(0, 1fr) minmax(280px, 0.82fr);
}
}
@media (max-width: 860px) {
.vg-set__forms {
grid-template-columns: 1fr;
}
.vg-set__group--wide,
#set-appearance,
#set-notify,
#set-voice {
grid-column: auto;
}
.vg-set__field-grid--account,
.vg-set__engine-grid {
grid-template-columns: 1fr;
}
.vg-set__field--wide {
grid-column: auto;
}
.vg-set__group-head--split {
flex-direction: column;
}
}
@media (max-width: 720px) {
.vg-set__mast {
display: block;
margin-bottom: var(--sp-4);
}
.vg-set__mast-meta {
justify-content: flex-start;
margin-top: var(--sp-2);
}
.vg-set {
gap: var(--sp-3);
}
.vg-set__rail {
display: block;
}
.vg-set__rail-card {
display: none;
}
.vg-set__nav {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
gap: 6px;
overflow-x: auto;
padding: 6px;
scrollbar-width: thin;
}
.vg-set__nav-kicker {
display: none;
}
.vg-set__nav-item {
flex: 0 0 auto;
min-height: 36px;
padding: 0 10px;
white-space: nowrap;
}
.vg-set__forms {
gap: var(--sp-3);
}
.vg-set__group {
padding: var(--sp-3);
}
.vg-set__profile,
.vg-set__field,
.vg-set__control-block,
.vg-set__opt,
.vg-set__range-row {
padding: 10px;
}
.vg-set__avatar {
width: 38px;
height: 38px;
font-size: 16px;
}
.vg-set__engine-grid,
.vg-set__range-row {
gap: var(--sp-2);
}
.vg-set__range-row {
grid-template-columns: 1fr;
}
.vg-set__ops {
width: 100%;
}
.vg-set__foot {
margin-top: var(--sp-3);
}
}
@media (max-width: 360px) {
.vg-set__seg {
grid-template-columns: 1fr;
}
.vg-set__opt {
align-items: flex-start;
}
}