feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG
web (Vite+React19+TS, Cloudflare Pages 배포): - 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브 - 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정 - ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서 - 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨 api (FastAPI): - 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존) - services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 / 턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG - store: DB off 폴백(in-memory), sessions 실구현 검증: - web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200 - api: app.main import 통과 - 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시) - E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
parent
859ab26314
commit
24b1b7a6e1
84 changed files with 19645 additions and 107 deletions
122
apps/web/src/pages/session-review/ValenceChart.tsx
Normal file
122
apps/web/src/pages/session-review/ValenceChart.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/* =====================================================================
|
||||
ValenceChart — 감정 밸런스 타임라인 (SVG 라인 차트).
|
||||
외부 ref의 Emotional Valence Timeline 차용 → Vignette 토큰 리스킨.
|
||||
· 내담자 valence = 실선(테라코타 --clay)
|
||||
· 상담자 baseline = 점선(세이지 --accent-bright)
|
||||
범례·시간축·0선 포함. preserveAspectRatio="none" 로 컨테이너 채움.
|
||||
순흑/순백 금지 — stroke 는 토큰 currentColor 계열만.
|
||||
===================================================================== */
|
||||
|
||||
import type { ValencePoint } from "./mock";
|
||||
|
||||
export interface ValenceChartProps {
|
||||
client: ValencePoint[];
|
||||
baseline: ValencePoint[];
|
||||
/** 시간축 라벨 (좌→우) */
|
||||
xLabels: string[];
|
||||
}
|
||||
|
||||
/** valence(-1~1) → SVG y(0~100, 위가 +1). */
|
||||
function toY(v: number): number {
|
||||
const clamped = Math.max(-1, Math.min(1, v));
|
||||
return 50 - clamped * 45; // +1 → 5, 0 → 50, -1 → 95 (상하 5% 여백)
|
||||
}
|
||||
|
||||
/** 점 배열 → 부드러운 Catmull-Rom→베지어 path d. (단조 곡선, 과한 출렁임 없게) */
|
||||
function smoothPath(points: ValencePoint[]): string {
|
||||
if (points.length === 0) return "";
|
||||
const pts = points.map((p) => ({ x: p.t * 100, y: toY(p.v) }));
|
||||
if (pts.length === 1) return `M${pts[0].x},${pts[0].y}`;
|
||||
|
||||
let d = `M${pts[0].x.toFixed(2)},${pts[0].y.toFixed(2)}`;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[i - 1] ?? pts[i];
|
||||
const p1 = pts[i];
|
||||
const p2 = pts[i + 1];
|
||||
const p3 = pts[i + 2] ?? p2;
|
||||
// Catmull-Rom → 베지어 (tension 1/6)
|
||||
const c1x = p1.x + (p2.x - p0.x) / 6;
|
||||
const c1y = p1.y + (p2.y - p0.y) / 6;
|
||||
const c2x = p2.x - (p3.x - p1.x) / 6;
|
||||
const c2y = p2.y - (p3.y - p1.y) / 6;
|
||||
d += ` C${c1x.toFixed(2)},${c1y.toFixed(2)} ${c2x.toFixed(2)},${c2y.toFixed(2)} ${p2.x.toFixed(2)},${p2.y.toFixed(2)}`;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
export function ValenceChart({ client, baseline, xLabels }: ValenceChartProps) {
|
||||
const clientPath = smoothPath(client);
|
||||
const baselinePath = smoothPath(baseline);
|
||||
|
||||
return (
|
||||
<div className="sr-chart">
|
||||
<div className="sr-chart__legend">
|
||||
<span className="sr-legend-item">
|
||||
<span className="sr-legend-line sr-legend-line--client" />
|
||||
내담자 정서가
|
||||
</span>
|
||||
<span className="sr-legend-item">
|
||||
<span className="sr-legend-line sr-legend-line--baseline" />
|
||||
상담자 기준선
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="sr-chart__plot">
|
||||
<div className="sr-chart__yaxis" aria-hidden="true">
|
||||
<span>+1.0</span>
|
||||
<span>0.0</span>
|
||||
<span>-1.0</span>
|
||||
</div>
|
||||
|
||||
<div className="sr-chart__canvas">
|
||||
<svg
|
||||
className="sr-chart__svg"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label="회기 동안 내담자 정서가와 상담자 기준선의 변화 추이"
|
||||
>
|
||||
{/* 0선 (중립) */}
|
||||
<line
|
||||
x1="0"
|
||||
y1="50"
|
||||
x2="100"
|
||||
y2="50"
|
||||
stroke="var(--hair)"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{/* 상담자 baseline (점선, 세이지) */}
|
||||
<path
|
||||
d={baselinePath}
|
||||
fill="none"
|
||||
stroke="var(--accent-bright)"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="4 4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
opacity="0.7"
|
||||
/>
|
||||
{/* 내담자 valence (실선, 테라코타) */}
|
||||
<path
|
||||
d={clientPath}
|
||||
fill="none"
|
||||
stroke="var(--clay)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="sr-chart__xaxis" aria-hidden="true">
|
||||
{xLabels.map((label, i) => (
|
||||
<span key={i}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
305
apps/web/src/pages/session-review/mock.ts
Normal file
305
apps/web/src/pages/session-review/mock.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
/* =====================================================================
|
||||
SessionReview 더미(mock) 데이터.
|
||||
⚠️ 전부 mock — 실제 백엔드 연동 전, 화면이 살아있게 채우는 용도.
|
||||
백엔드 계약(SessionEndResponse/digest)이 붙으면 이 파일을 교체한다.
|
||||
톤(§5.7): 점수/등급/합불 없음 → 성장 신호·한줄요약·기법분포·잘한순간·개선점.
|
||||
===================================================================== */
|
||||
|
||||
/** 상담 기법 군집 (taxonomy 군집 기반: 관계/탐색/개입/안정/구조화). */
|
||||
export type TechniqueKind =
|
||||
| "empathy" // 관계 — 공감/반영적 경청
|
||||
| "explore" // 탐색 — 개방형 질문
|
||||
| "reflect" // 탐색 — 반영
|
||||
| "confront" // 개입 — 직면
|
||||
| "closed"; // 살펴볼 점 — 닫힌 질문
|
||||
|
||||
export interface Technique {
|
||||
kind: TechniqueKind;
|
||||
/** 칩에 표시할 라벨 */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type Speaker = "learner" | "client";
|
||||
export type NoteAuthor = "ai" | "instructor";
|
||||
|
||||
export interface SupervisorNote {
|
||||
/** ai = 자동 코멘트, instructor = 교수자 직접 */
|
||||
author: NoteAuthor;
|
||||
/** good(잘한 점) → accent-tint, watch(살펴볼 점) → warn-tint */
|
||||
tone: "good" | "watch";
|
||||
title: string;
|
||||
/** 본문. quote 부분은 컴포넌트가 italic 강조 */
|
||||
body: string;
|
||||
/** 인용 대안 발화(있으면 italic 강조 블록) */
|
||||
quote?: string;
|
||||
}
|
||||
|
||||
export interface Turn {
|
||||
id: string;
|
||||
/** "MM:SS" 또는 "M:SS" */
|
||||
ts: string;
|
||||
speaker: Speaker;
|
||||
/** 화자 표기 (예: "나 (학습자)", "서연 (내담자)") */
|
||||
who: string;
|
||||
text: string;
|
||||
/** 학습자 발화에만: 감지된 기법 라벨(0~2개) */
|
||||
techniques?: Technique[];
|
||||
/** 인라인 슈퍼바이저 노트(있으면 발화 아래 들여쓴 콜아웃) */
|
||||
note?: SupervisorNote;
|
||||
}
|
||||
|
||||
export interface PhaseSegment {
|
||||
key: "rapport" | "explore" | "intervene" | "closing";
|
||||
label: string;
|
||||
/** 소요 비례 가중치(flex) */
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface ValencePoint {
|
||||
/** 0~1 정규화 시간축 위치 */
|
||||
t: number;
|
||||
/** -1 ~ +1 정서가(valence) */
|
||||
v: number;
|
||||
}
|
||||
|
||||
export interface RubricRow {
|
||||
name: string;
|
||||
/** taxonomy 군집 라벨 (관계/탐색/개입/안정/구조화) */
|
||||
cluster: string;
|
||||
/** 0~1 — 빈도+적절성 종합(점수 아님, 막대 길이) */
|
||||
ratio: number;
|
||||
/** good = 충분/적절, watch = 과다·과소(살펴볼 점) */
|
||||
quality: "good" | "watch";
|
||||
/** 빈도 메타 (예: "8회 · 적절") */
|
||||
freq: string;
|
||||
}
|
||||
|
||||
export interface GrowthPoint {
|
||||
title: string;
|
||||
body: string;
|
||||
/** 연결된 타임라인 발화 id(클릭 점프) */
|
||||
jumpTo?: string;
|
||||
}
|
||||
|
||||
export interface SessionReviewData {
|
||||
client: {
|
||||
name: string;
|
||||
initial: string;
|
||||
persona: string;
|
||||
};
|
||||
date: string; // 표시용 한국어
|
||||
durationLabel: string;
|
||||
/** 진행 단계 신호 (성장 신호 톤 — 점수 아님) */
|
||||
reachedPhase: string;
|
||||
/** SESSION 칸 — 성장 신호 (점수 금지) */
|
||||
sessionSignal: string;
|
||||
/** SUPERVISOR 칸 — 검토 상태 */
|
||||
supervisorState: string;
|
||||
supervisorName: string;
|
||||
/** 한 줄 요약. hl 토큰(<hl>…</hl>)으로 accent 강조 구간 표시 */
|
||||
summary: string;
|
||||
phases: PhaseSegment[];
|
||||
/** 단계 막대 시간축 라벨 */
|
||||
phaseAxis: string[];
|
||||
valenceAxis: string[];
|
||||
clientValence: ValencePoint[];
|
||||
counselorBaseline: ValencePoint[];
|
||||
turns: Turn[];
|
||||
rubric: RubricRow[];
|
||||
goodMoments: GrowthPoint[];
|
||||
growthPoints: GrowthPoint[];
|
||||
nextLine: string;
|
||||
/** AI 내담자 피드백 (italic 1인칭) */
|
||||
clientFeedback: string;
|
||||
}
|
||||
|
||||
export const MOCK_REVIEW: SessionReviewData = {
|
||||
client: {
|
||||
name: "서연",
|
||||
initial: "서",
|
||||
persona: "17세 · 우울 호소 청소년",
|
||||
},
|
||||
date: "2026-06-25",
|
||||
durationLabel: "32분 14초",
|
||||
reachedPhase: "탐색 단계까지 진행",
|
||||
sessionSignal: "라포 형성 신호 뚜렷",
|
||||
supervisorState: "검토 대기",
|
||||
supervisorName: "김",
|
||||
summary:
|
||||
"라포는 <hl>안정적으로 형성</hl>됐어요. 다만 탐색 단계에서 닫힌 질문이 몇 차례 반복되면서, 서연이 막 열기 시작한 자기개방이 잠깐씩 멈췄습니다.",
|
||||
phases: [
|
||||
{ key: "rapport", label: "라포 형성", weight: 1.4 },
|
||||
{ key: "explore", label: "탐색", weight: 2.6 },
|
||||
{ key: "intervene", label: "개입", weight: 1.0 },
|
||||
{ key: "closing", label: "정리", weight: 0.8 },
|
||||
],
|
||||
phaseAxis: ["00:00", "09:12", "24:30", "32:14"],
|
||||
valenceAxis: ["0분", "8분", "16분", "24분", "32분"],
|
||||
// 내담자 valence: 위축(낮음) → 자기개방 들어가며 완만히 상승, 닫힌 질문 구간(16~17분) 잠깐 하강 → 회복
|
||||
clientValence: [
|
||||
{ t: 0.0, v: -0.55 },
|
||||
{ t: 0.12, v: -0.4 },
|
||||
{ t: 0.24, v: -0.18 },
|
||||
{ t: 0.36, v: 0.05 },
|
||||
{ t: 0.5, v: -0.32 }, // 닫힌 질문 구간 위축
|
||||
{ t: 0.62, v: -0.1 },
|
||||
{ t: 0.76, v: 0.18 },
|
||||
{ t: 0.9, v: 0.28 },
|
||||
{ t: 1.0, v: 0.22 },
|
||||
],
|
||||
// 상담자 baseline: 안정적 중립 톤 유지(점선)
|
||||
counselorBaseline: [
|
||||
{ t: 0.0, v: 0.1 },
|
||||
{ t: 0.18, v: 0.16 },
|
||||
{ t: 0.36, v: 0.12 },
|
||||
{ t: 0.5, v: 0.18 },
|
||||
{ t: 0.64, v: 0.14 },
|
||||
{ t: 0.8, v: 0.2 },
|
||||
{ t: 1.0, v: 0.16 },
|
||||
],
|
||||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
ts: "10:42",
|
||||
speaker: "learner",
|
||||
who: "나 (학습자)",
|
||||
text: "학교에 가는 게 많이 버겁게 느껴지는 것 같아요. 혹시 어떤 순간이 가장 힘든지 조금 더 들려줄 수 있을까요?",
|
||||
techniques: [{ kind: "explore", label: "탐색 · 개방형 질문" }],
|
||||
note: {
|
||||
author: "instructor",
|
||||
tone: "good",
|
||||
title: "잘한 점",
|
||||
body: '서연이 먼저 꺼내기 어려워하던 지점을 압박 없이 열어줬어요. 시간 여유를 둔 표현("조금 더")이 안전감을 만들었습니다.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
ts: "11:05",
|
||||
speaker: "client",
|
||||
who: "서연 (내담자)",
|
||||
text: "아침에… 눈을 뜨면요. 그냥 천장만 보고 있어요. 일어나야 하는 건 아는데, 몸이 안 움직여요. 그게 제일… 무서워요.",
|
||||
},
|
||||
{
|
||||
id: "t3",
|
||||
ts: "11:28",
|
||||
speaker: "learner",
|
||||
who: "나 (학습자)",
|
||||
text: "눈을 떠도 몸이 따라주지 않는 그 무력감이, 무섭게까지 느껴지는군요.",
|
||||
techniques: [{ kind: "empathy", label: "공감 · 반영적 경청" }],
|
||||
note: {
|
||||
author: "ai",
|
||||
tone: "good",
|
||||
title: "잘한 점",
|
||||
body: '내담자의 단어("무서워요")를 그대로 반영하며 감정의 강도까지 받아냈어요. 직후 서연의 자기개방이 한 단계 깊어졌습니다.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "t4",
|
||||
ts: "16:54",
|
||||
speaker: "learner",
|
||||
who: "나 (학습자)",
|
||||
text: "그럼 학교는 그냥 가기 싫은 거예요?",
|
||||
techniques: [{ kind: "closed", label: "닫힌 질문" }],
|
||||
note: {
|
||||
author: "instructor",
|
||||
tone: "watch",
|
||||
title: "살펴볼 점",
|
||||
body: "'예/아니오'로 닫히는 질문이라 서연이 \"그런 건 아니고요…\" 하고 짧게 답했어요. 같은 의도를",
|
||||
quote:
|
||||
'"학교를 떠올리면 어떤 마음이 먼저 드는지 들려줄래요?" 처럼 열어두면 자기개방이 이어졌을 거예요.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "t5",
|
||||
ts: "17:10",
|
||||
speaker: "client",
|
||||
who: "서연 (내담자)",
|
||||
text: "…그런 건 아니고요. 그냥… 모르겠어요.",
|
||||
},
|
||||
{
|
||||
id: "t6",
|
||||
ts: "21:36",
|
||||
speaker: "learner",
|
||||
who: "나 (학습자)",
|
||||
text: "방금 잠깐 말문이 막혔던 것 같아요. 괜찮아요, 천천히 해도 돼요. 요즘 친구들과 함께 있을 때는 어떤 느낌이 드는지 궁금해요.",
|
||||
techniques: [
|
||||
{ kind: "reflect", label: "반영" },
|
||||
{ kind: "explore", label: "탐색" },
|
||||
],
|
||||
note: {
|
||||
author: "ai",
|
||||
tone: "good",
|
||||
title: "잘한 점",
|
||||
body: "닫힌 질문 뒤 위축을 알아채고 페이스를 늦춘 회복이 좋았어요. 곧바로 열린 질문으로 전환해 대화가 다시 흐르기 시작했습니다.",
|
||||
},
|
||||
},
|
||||
],
|
||||
rubric: [
|
||||
{
|
||||
name: "반영적 경청",
|
||||
cluster: "관계",
|
||||
ratio: 0.82,
|
||||
quality: "good",
|
||||
freq: "6회 · 적절",
|
||||
},
|
||||
{
|
||||
name: "공감 · 타당화",
|
||||
cluster: "관계",
|
||||
ratio: 0.74,
|
||||
quality: "good",
|
||||
freq: "5회 · 적절",
|
||||
},
|
||||
{
|
||||
name: "개방형 질문",
|
||||
cluster: "탐색",
|
||||
ratio: 0.48,
|
||||
quality: "watch",
|
||||
freq: "3회 · 더 늘려보기",
|
||||
},
|
||||
{
|
||||
name: "닫힌 질문",
|
||||
cluster: "탐색",
|
||||
ratio: 0.58,
|
||||
quality: "watch",
|
||||
freq: "5회 · 다소 잦음",
|
||||
},
|
||||
{
|
||||
name: "침묵 견디기",
|
||||
cluster: "안정",
|
||||
ratio: 0.66,
|
||||
quality: "good",
|
||||
freq: "여백 활용 양호",
|
||||
},
|
||||
],
|
||||
goodMoments: [
|
||||
{
|
||||
title: "감정을 그대로 받아낸 반영",
|
||||
body: "서연의 표현을 빌려 무력감을 비춰줬고, 직후 자기개방이 깊어졌어요.",
|
||||
jumpTo: "t3",
|
||||
},
|
||||
{
|
||||
title: "위축을 알아챈 회복",
|
||||
body: "닫힌 질문 뒤 멈춤을 감지하고 페이스를 늦춰 안전감을 되찾았어요.",
|
||||
jumpTo: "t6",
|
||||
},
|
||||
],
|
||||
growthPoints: [
|
||||
{
|
||||
title: "닫힌 질문을 열어두기",
|
||||
body: "탐색 단계에서 '예/아니오' 질문이 몇 차례 반복됐어요. 의도는 같아도 열린 형태로 바꿔보면 좋겠어요.",
|
||||
jumpTo: "t4",
|
||||
},
|
||||
{
|
||||
title: "침묵을 조금 더 견디기",
|
||||
body: "서연이 망설일 때 바로 다음 질문을 채우기보다, 잠깐의 여백을 두면 스스로 더 말할 공간이 생겨요.",
|
||||
},
|
||||
{
|
||||
title: "감정 단어를 함께 명명하기",
|
||||
body: '"무섭다"처럼 등장한 감정 단어를 다음 회기에서 조금 더 풀어 물으면 자기이해가 깊어집니다.',
|
||||
},
|
||||
],
|
||||
nextLine:
|
||||
'"그 마음을 떠올리면, 가장 먼저 어떤 장면이 생각나는지 들려줄래요?"',
|
||||
clientFeedback:
|
||||
"선생님이 제 말을 진짜로 들어준다는 느낌이 들 때가 있었어요. 근데 중간에 '학교 가기 싫은 거냐'고 물었을 땐… 제 마음이 그렇게 단순하진 않은데, 하고 살짝 멈칫했어요. 그냥 그때 얼마나 막막했는지를 먼저 알아줬으면 했어요.",
|
||||
};
|
||||
668
apps/web/src/pages/session-review/session-review.css
Normal file
668
apps/web/src/pages/session-review/session-review.css
Normal file
|
|
@ -0,0 +1,668 @@
|
|||
/* =====================================================================
|
||||
SessionReview 페이지 전용 스타일 (이 페이지에서만 import).
|
||||
토큰(tokens.css) 변수만 참조 — 색/간격 추측 금지.
|
||||
철칙: border-left 강조선 0 · 이모지 0(아이콘은 inline SVG) · 카드덤프 0 ·
|
||||
순흑/순백 금지(paper/ink 토큰) · 강조는 weight+tint+kicker+dot ·
|
||||
점수/등급/합불 표기 없음(§5.7 톤다운: 성장 신호).
|
||||
외부 ref(session-review-external-ref.html)의 레이아웃/구조/인터랙션만 차용,
|
||||
Vignette 세이지틸·테라코타로 리스킨.
|
||||
===================================================================== */
|
||||
|
||||
.sr-root {
|
||||
max-width: var(--maxw);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── (1) 세션 헤더: 아바타 이니셜 + 메타 + 우측 스탯(성장 신호) ── */
|
||||
.sr-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-5);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
.sr-head__id {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-4);
|
||||
min-width: 0;
|
||||
}
|
||||
/* 아바타 이니셜 — 원형 예외 허용(아바타) */
|
||||
.sr-avatar {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-num);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--clay-deep);
|
||||
background: var(--clay-tint);
|
||||
}
|
||||
.sr-head__name {
|
||||
font-size: var(--fs-h3);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-strong);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.sr-head__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 5px;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.sr-head__meta .sr-mono {
|
||||
font-family: var(--font-num);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.sr-meta-sep {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--neutral-200);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* 우측 스탯 묶음 (SESSION 신호 + SUPERVISOR) — 점수 박스 대신 한줄 신호 */
|
||||
.sr-head__stats {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
}
|
||||
.sr-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 0 var(--sp-5);
|
||||
}
|
||||
.sr-stat + .sr-stat {
|
||||
border-left: 1px solid var(--hair); /* 데이터 구분선(강조바 아님, 1px 헤어라인) */
|
||||
}
|
||||
.sr-stat__lab {
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-kicker);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.sr-stat__val {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.sr-stat__val--accent {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
/* ── 2-3 컬럼 레이아웃 ── */
|
||||
.sr-cols {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.9fr) minmax(0, 1fr);
|
||||
gap: var(--sp-6);
|
||||
align-items: start;
|
||||
margin-top: var(--sp-6);
|
||||
}
|
||||
.sr-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
min-width: 0;
|
||||
}
|
||||
.sr-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
position: sticky;
|
||||
top: calc(var(--topbar-h) + var(--sp-5));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── 한 줄 요약 (24px / 600 — 에디토리얼 미니멀 핵심) ── */
|
||||
.sr-summary {
|
||||
font-size: var(--fs-h2);
|
||||
line-height: 1.5;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-strong);
|
||||
max-width: 56ch;
|
||||
}
|
||||
.sr-summary .sr-hl {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
/* ── 감정 밸런스 타임라인 (SVG 라인 차트) ── */
|
||||
.sr-chart {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.sr-chart__legend {
|
||||
display: flex;
|
||||
gap: var(--sp-4);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
.sr-legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-body);
|
||||
}
|
||||
.sr-legend-line {
|
||||
width: 18px;
|
||||
height: 0;
|
||||
border-top-width: 2px;
|
||||
border-top-style: solid;
|
||||
flex: none;
|
||||
}
|
||||
.sr-legend-line--client {
|
||||
border-top-color: var(--clay);
|
||||
}
|
||||
.sr-legend-line--baseline {
|
||||
border-top-style: dashed;
|
||||
border-top-color: var(--accent-bright);
|
||||
}
|
||||
.sr-chart__plot {
|
||||
position: relative;
|
||||
height: 168px;
|
||||
width: 100%;
|
||||
}
|
||||
.sr-chart__yaxis {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 18px;
|
||||
width: 30px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
text-align: right;
|
||||
padding-right: 8px;
|
||||
font-family: var(--font-num);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.sr-chart__canvas {
|
||||
position: absolute;
|
||||
left: 34px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 18px;
|
||||
}
|
||||
.sr-chart__svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
.sr-chart__xaxis {
|
||||
position: absolute;
|
||||
left: 34px;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: var(--font-num);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── 회기 흐름 단계 막대 (가로, 너비=소요 비례) ── */
|
||||
.sr-phasebar__track {
|
||||
display: flex;
|
||||
height: 36px;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--hair);
|
||||
}
|
||||
.sr-phase {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-body);
|
||||
border-right: 1px solid var(--hair);
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sr-phase:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.sr-phase__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-bright);
|
||||
flex: none;
|
||||
}
|
||||
.sr-phasebar__axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 7px;
|
||||
font-family: var(--font-num);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ── 세션 트랜스크립트 ── */
|
||||
.sr-tx__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
.sr-tx__filters {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.sr-chip-toggle {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 100px;
|
||||
padding: 4px 12px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--dur-base) var(--ease-out),
|
||||
color var(--dur-base) var(--ease-out),
|
||||
border-color var(--dur-base) var(--ease-out);
|
||||
}
|
||||
.sr-chip-toggle:hover {
|
||||
color: var(--text-strong);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.sr-chip-toggle[aria-pressed="true"] {
|
||||
color: var(--accent-deep);
|
||||
background: var(--accent-tint);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.sr-turns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sr-turn {
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
column-gap: var(--sp-4);
|
||||
padding: var(--sp-4) 0;
|
||||
}
|
||||
.sr-turn + .sr-turn {
|
||||
border-top: 1px solid var(--paper-2);
|
||||
}
|
||||
.sr-turn__rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-top: 3px;
|
||||
}
|
||||
.sr-turn__ts {
|
||||
font-family: var(--font-num);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.sr-turn__node {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
.sr-turn__node--learner {
|
||||
background: var(--accent-bright);
|
||||
}
|
||||
.sr-turn__node--client {
|
||||
background: var(--clay);
|
||||
}
|
||||
.sr-turn__stem {
|
||||
width: 2px;
|
||||
flex: 1;
|
||||
min-height: 8px;
|
||||
background: var(--hair);
|
||||
}
|
||||
.sr-turn__body {
|
||||
min-width: 0;
|
||||
}
|
||||
.sr-turn__who {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 9px;
|
||||
margin-bottom: 5px;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.sr-turn__who--learner {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
.sr-turn__who--client {
|
||||
color: var(--clay-deep);
|
||||
}
|
||||
.sr-turn__said {
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.6;
|
||||
color: var(--text-strong);
|
||||
max-width: 62ch;
|
||||
}
|
||||
.sr-turn__said--client {
|
||||
color: var(--text-body);
|
||||
}
|
||||
|
||||
/* 기법 라벨 칩 (배경 틴트, border 없음, 11px) */
|
||||
.sr-technique {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 9px;
|
||||
border-radius: 100px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.sr-technique__dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
.sr-technique--empathy {
|
||||
color: var(--pos-text);
|
||||
background: var(--pos-tint);
|
||||
}
|
||||
.sr-technique--empathy .sr-technique__dot {
|
||||
background: var(--pos-solid);
|
||||
}
|
||||
.sr-technique--explore {
|
||||
color: var(--accent-deep);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
.sr-technique--explore .sr-technique__dot {
|
||||
background: var(--accent);
|
||||
}
|
||||
.sr-technique--reflect {
|
||||
color: var(--info-text);
|
||||
background: var(--info-tint);
|
||||
}
|
||||
.sr-technique--reflect .sr-technique__dot {
|
||||
background: var(--info-solid);
|
||||
}
|
||||
.sr-technique--confront {
|
||||
color: var(--clay-deep);
|
||||
background: var(--clay-tint);
|
||||
}
|
||||
.sr-technique--confront .sr-technique__dot {
|
||||
background: var(--clay);
|
||||
}
|
||||
.sr-technique--closed {
|
||||
color: var(--warn-text);
|
||||
background: var(--warn-tint);
|
||||
}
|
||||
.sr-technique--closed .sr-technique__dot {
|
||||
background: var(--warn-solid);
|
||||
}
|
||||
|
||||
/* 인라인 슈퍼바이저 노트 — 말풍선 금지, 발화 아래 들여쓴 tint 콜아웃 */
|
||||
.sr-note {
|
||||
margin-top: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--radius);
|
||||
max-width: 62ch;
|
||||
transition:
|
||||
opacity var(--dur-base) var(--ease-out),
|
||||
transform var(--dur-base) var(--ease-out);
|
||||
}
|
||||
.sr-note--ai {
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
.sr-note--warn {
|
||||
background: var(--warn-tint);
|
||||
}
|
||||
.sr-note__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.sr-note--ai .sr-note__head {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
.sr-note--warn .sr-note__head {
|
||||
color: var(--warn-text);
|
||||
}
|
||||
.sr-note__head svg {
|
||||
flex: none;
|
||||
}
|
||||
.sr-note__tag {
|
||||
font-family: var(--font-num);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sr-note__txt {
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.6;
|
||||
color: var(--text-body);
|
||||
}
|
||||
.sr-note__txt .sr-quote {
|
||||
color: var(--text-strong);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── 우측: 스킬 루브릭 (가로 바 = 빈도+적절성, 점수 아님) ── */
|
||||
.sr-rubric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-5);
|
||||
}
|
||||
.sr-rubric__row .sr-rubric__top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sr-rubric__name {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.sr-rubric__qual {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sr-rubric__qual--good {
|
||||
color: var(--pos-text);
|
||||
}
|
||||
.sr-rubric__qual--watch {
|
||||
color: var(--warn-text);
|
||||
}
|
||||
.sr-rubric__qual .sr-technique__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.sr-rubric__qual--good .sr-technique__dot {
|
||||
background: var(--pos-solid);
|
||||
}
|
||||
.sr-rubric__qual--watch .sr-technique__dot {
|
||||
background: var(--warn-solid);
|
||||
}
|
||||
.sr-rubric__cluster {
|
||||
font-family: var(--font-num);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ── 우측: AI 내담자 피드백 (다크 카드 = bg-stage 톤, italic 1인칭) ── */
|
||||
.sr-feedback {
|
||||
background: var(--bg-stage);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-5);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.sr-feedback__kicker {
|
||||
font-family: var(--font-num);
|
||||
font-size: var(--fs-kicker);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent-bright);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
.sr-feedback__kicker .sr-technique__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent-bright);
|
||||
}
|
||||
.sr-feedback__quote {
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.65;
|
||||
color: #d7ddda; /* bg-stage 위 본문 — 순백 금지, 밝은 세이지그레이 */
|
||||
font-style: italic;
|
||||
}
|
||||
.sr-feedback__src {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: var(--sp-4);
|
||||
padding-top: var(--sp-3);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: var(--font-num);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
color: #8a9794;
|
||||
}
|
||||
|
||||
/* ── 잘한 순간 / 개선점 리스트 (각 항목 타임라인 마커 연결) ── */
|
||||
.sr-points {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
.sr-point {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
column-gap: 11px;
|
||||
align-items: start;
|
||||
}
|
||||
.sr-point__mk {
|
||||
margin-top: 7px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
.sr-points--good .sr-point__mk {
|
||||
background: var(--pos-solid);
|
||||
}
|
||||
.sr-points--grow .sr-point__mk {
|
||||
background: var(--warn-solid);
|
||||
}
|
||||
.sr-point__h {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--text-strong);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.sr-point__d {
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.6;
|
||||
color: var(--text-body);
|
||||
}
|
||||
.sr-point__at {
|
||||
font-family: var(--font-num);
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
margin-left: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sr-point__at:hover {
|
||||
color: var(--accent-deep);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 다음에 시도할 한 문장 */
|
||||
.sr-nextline {
|
||||
background: var(--accent-tint);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-4) var(--sp-5);
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
.sr-nextline__lab {
|
||||
font-family: var(--font-num);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--accent-deep);
|
||||
margin-bottom: 7px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sr-nextline__q {
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.6;
|
||||
color: var(--text-strong);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 활성 발화(타임라인 마커 클릭으로 점프) 강조 — 좌측바 아님, tint 펄스 */
|
||||
.sr-turn--active .sr-turn__said {
|
||||
background: var(--accent-tint);
|
||||
border-radius: var(--radius-sm);
|
||||
margin: 0 -8px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.sr-cols {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.sr-right {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sr-note,
|
||||
.sr-chip-toggle,
|
||||
.sr-turn--active .sr-turn__said {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue