대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·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:
parent
cb2aebd76c
commit
085460b5e0
327 changed files with 31226 additions and 1829 deletions
File diff suppressed because it is too large
Load diff
164
apps/web/src/pages/AvatarExpressionLab.tsx
Normal file
164
apps/web/src/pages/AvatarExpressionLab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
apps/web/src/pages/AvatarPreview.tsx
Normal file
74
apps/web/src/pages/AvatarPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;}
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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 ? "발화 보내기" : "마이크 켜기"}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
내담자가 남긴 것
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
239
apps/web/src/pages/avatar-expression-lab.css
Normal file
239
apps/web/src/pages/avatar-expression-lab.css
Normal 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));
|
||||
}
|
||||
}
|
||||
8
apps/web/src/pages/avatar-preview.css
Normal file
8
apps/web/src/pages/avatar-preview.css
Normal 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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue