feat(coach): AI 코칭 버튼 및 버블 팝업 대화형 조언 기능 구현

This commit is contained in:
Yun Chan 2026-08-18 22:15:19 +09:00
parent 8a34dd2afc
commit 70d7cb34a3
36 changed files with 4521 additions and 1264 deletions

View file

@ -177,16 +177,43 @@ async function routeCoachVoiceSession(page: Page, options: CoachVoiceOptions = {
);
});
await page.route(`**/api/sessions/${SESSION_ID}/live-coach`, (route) =>
route.fulfill(
await page.route(`**/api/sessions/${SESSION_ID}/live-coach`, async (route) => {
if (route.request().method() === "POST") {
await route.fulfill(
jsonRoute({
status: "ready",
tone: "pos",
focus: "emotion",
title: "공감과 탐색",
message: "내담자의 답답한 마음에 먼저 공감하고 구체적인 장면을 열린 질문으로 물어보세요.",
next_utterance: "학교 생각만 하면 답답하다고 했는데, 어떤 부분이 제일 마음에 걸려요?",
rationale: "감정 타당화 후 구체 촉발 탐색",
quota: { remaining: Math.max(0, coachRemaining - 1), max: coachMax },
credit_events: [
{
event_id: "c-001",
session_id: SESSION_ID,
turn_seq: 1,
event_type: "use",
delta: -1,
balance: Math.max(0, coachRemaining - 1),
reason: "live coaching request",
created_at: new Date().toISOString(),
},
],
}),
);
return;
}
await route.fulfill(
jsonRoute({
source: "database",
quota: { remaining: coachRemaining, max: coachMax },
credit_events: [],
events: [],
}),
),
);
);
});
}
async function openSession(page: Page) {
@ -419,4 +446,49 @@ test.describe("AI 코치·음성 UI — 고지·게이트·피드백 모드", ()
const label = await mic.getAttribute("aria-label");
expect(label ?? "").not.toBe("");
});
// usecase: AI 코칭 버튼 클릭 시 버블 팝업이 노출되고 추천 칩 및 채팅 조언을 얻을 수 있다
test("AI 코칭 버튼 클릭 시 버블 팝업이 뜨고 채팅 질문과 추천 발화 적용이 동작한다", async ({ page }) => {
await routeCoachVoiceSession(page, { coachRemaining: 3, coachMax: 3 });
await openSession(page);
const triggerBtn = page.getByRole("button", { name: /^AI 코칭, 남은 기회/ });
await expect(triggerBtn).toBeVisible();
await expect(triggerBtn).toContainText("3");
// 1. 버블 팝업 열기
await triggerBtn.click({ force: true });
const popup = page.getByRole("dialog", { name: "AI 슈퍼바이저 코칭 버블" });
await expect(popup).toBeVisible();
await expect(popup).toContainText("AI 슈퍼바이저 코칭");
await expect(popup).toContainText("기회 3/3");
// 2. 추천 칩 확인 및 클릭
const recommendChip = popup.getByRole("button", { name: "💡 다음 추천 발화" });
await expect(recommendChip).toBeVisible();
await recommendChip.dispatchEvent("click");
// 3. 조언 메시지 및 추천 발화 노출 확인
await expect(popup.locator(".sx-coach-msg--learner")).toContainText("다음 추천 발화와 방향을 알려줘");
await expect(popup.locator(".sx-coach-msg--coach")).toBeVisible();
const insertBtn = popup.getByRole("button", { name: "발화창에 넣기" });
await expect(insertBtn).toBeAttached();
// 4. [발화창에 넣기] 클릭 시 하단 발화 입력창에 텍스트 반영
await insertBtn.dispatchEvent("click");
const composer = page.getByPlaceholder("학습자 발화를 입력하세요.");
await expect(composer).not.toHaveValue("");
// 5. 직접 질문 채팅 입력 및 전송
const coachInput = popup.getByPlaceholder("코치에게 조언을 구해보세요... (Enter 전송)");
await coachInput.fill("내담자의 저항을 부드럽게 다루려면 어떻게 하나요?");
await popup.getByRole("button", { name: "코칭 질문 전송" }).dispatchEvent("click");
await expect(popup.locator(".sx-coach-msg--learner").last()).toContainText("내담자의 저항을 부드럽게 다루려면");
await expect(popup.locator(".sx-coach-msg--coach").last()).toBeVisible();
// 6. 팝업 닫기 버튼 클릭 시 닫힘 확인
await popup.getByRole("button", { name: "코칭 팝업 닫기" }).dispatchEvent("click");
await expect(popup).toBeHidden();
});
});

View file

@ -37,17 +37,17 @@ const designDoc = await read(designDocPath);
// 페이지/아트 전용 CSS는 집중 화면의 국소 팔레트를 소유할 수 있다. 다만 전역
// 토큰으로 승격하지 않은 raw color는 이 기준선 이상 늘어나면 실패시켜 예외가
// 무제한 확산되는 것을 막는다. 공통 ui/shell은 아래 별도 규칙으로 0개를 강제한다.
// 2026-08-18: 전수 토큰화 완료. 모든 페이지와 컴포넌트 CSS에서 raw color가 0개로 정화됨.
const rawColorBudgets = {
"src/pages/session/session.css": 216,
"src/pages/session-review/session-review.css": 29,
"src/pages/learner-home.css": 25,
"src/components/auth/auth-shell.css": 20,
"src/pages/login/login.css": 11,
"src/components/avatar/client-avatar.css": 7,
"src/pages/avatar-expression-lab.css": 7,
"src/pages/avatar-preview.css": 5,
"src/pages/pending-approval.css": 3,
"src/pages/session/session.css": 0,
"src/pages/session-review/session-review.css": 0,
"src/pages/learner-home.css": 0,
"src/components/auth/auth-shell.css": 0,
"src/pages/login/login.css": 0,
"src/components/avatar/client-avatar.css": 0,
"src/pages/avatar-expression-lab.css": 0,
"src/pages/avatar-preview.css": 0,
"src/pages/pending-approval.css": 0,
};
for (const [relativePath, budget] of Object.entries(rawColorBudgets)) {

View file

@ -41,17 +41,17 @@
--auth-room-veil:
linear-gradient(
90deg,
rgba(247, 244, 236, 0.94) 0%,
rgba(243, 238, 227, 0.86) 48%,
rgba(238, 231, 217, 0.72) 74%,
rgba(232, 223, 207, 0.62) 100%
color-mix(in srgb, var(--bg-app) 94%, transparent) 0%,
color-mix(in srgb, var(--bg-app) 86%, transparent) 48%,
color-mix(in srgb, var(--bg-app) 72%, transparent) 74%,
color-mix(in srgb, var(--bg-app) 62%, transparent) 100%
);
--auth-room-shade: linear-gradient(180deg, rgba(255, 253, 248, 0.08), rgba(120, 101, 72, 0.12));
--auth-room-shade: linear-gradient(180deg, color-mix(in srgb, var(--bg-surface) 8%, transparent), color-mix(in srgb, var(--clay) 12%, transparent));
--auth-hero-text: var(--text-strong);
--auth-hero-muted: color-mix(in srgb, var(--text-body) 88%, transparent);
--auth-hero-surface: rgba(255, 254, 251, 0.48);
--auth-hero-border: rgba(101, 86, 62, 0.22);
--auth-hero-chip: rgba(255, 254, 251, 0.62);
--auth-hero-surface: color-mix(in srgb, var(--bg-surface) 48%, transparent);
--auth-hero-border: color-mix(in srgb, var(--border-strong) 42%, transparent);
--auth-hero-chip: color-mix(in srgb, var(--bg-surface) 62%, transparent);
background:
var(--auth-room-veil),
var(--asset-login-room) center / cover no-repeat;
@ -61,17 +61,17 @@
--auth-room-veil:
linear-gradient(
90deg,
rgba(7, 16, 14, 0.84) 0%,
rgba(10, 21, 19, 0.72) 48%,
rgba(10, 21, 19, 0.55) 74%,
rgba(10, 21, 19, 0.62) 100%
color-mix(in srgb, var(--bg-app) 84%, transparent) 0%,
color-mix(in srgb, var(--bg-app) 72%, transparent) 48%,
color-mix(in srgb, var(--bg-app) 55%, transparent) 74%,
color-mix(in srgb, var(--bg-app) 62%, transparent) 100%
);
--auth-room-shade: linear-gradient(180deg, rgba(3, 9, 8, 0.18), rgba(3, 9, 8, 0.42));
--auth-hero-text: #edf4f2;
--auth-hero-muted: rgba(237, 244, 242, 0.72);
--auth-hero-surface: rgba(237, 244, 242, 0.08);
--auth-hero-border: rgba(255, 255, 255, 0.14);
--auth-hero-chip: rgba(255, 255, 255, 0.08);
--auth-room-shade: linear-gradient(180deg, color-mix(in srgb, var(--bg-app) 18%, transparent), color-mix(in srgb, var(--bg-app) 42%, transparent));
--auth-hero-text: var(--text-strong);
--auth-hero-muted: color-mix(in srgb, var(--text-strong) 72%, transparent);
--auth-hero-surface: color-mix(in srgb, var(--text-strong) 8%, transparent);
--auth-hero-border: color-mix(in srgb, var(--border-subtle) 80%, transparent);
--auth-hero-chip: color-mix(in srgb, var(--text-strong) 8%, transparent);
}
@media (max-width: 620px) {

View file

@ -9,10 +9,10 @@
position:relative;width:100%;border-radius:50%;
display:flex;align-items:center;justify-content:center;overflow:hidden;
background:
radial-gradient(circle at 50% 38%, rgba(251,250,248,.98) 0%, rgba(246,240,236,.9) 42%, rgba(238,244,242,.76) 64%, rgba(145,200,189,.22) 84%, rgba(30,39,36,.18) 100%);
radial-gradient(circle at 50% 38%, color-mix(in srgb, var(--bg-surface) 98%, transparent) 0%, color-mix(in srgb, var(--bg-surface-2) 90%, transparent) 42%, color-mix(in srgb, var(--accent-tint) 76%, transparent) 64%, color-mix(in srgb, var(--accent) 22%, transparent) 84%, color-mix(in srgb, var(--text-strong) 18%, transparent) 100%);
box-shadow:
inset 0 0 0 1px rgba(251,250,248,.28),
inset 0 -24px 40px rgba(28,42,42,.13);
inset 0 0 0 1px color-mix(in srgb, var(--bg-surface) 28%, transparent),
inset 0 -24px 40px color-mix(in srgb, var(--text-strong) 13%, transparent);
}
.vg-avatar__aura{
position:absolute;inset:-12%;border-radius:50%;pointer-events:none;

View file

@ -1,5 +1,5 @@
import { useEffect, useLayoutEffect, useRef, type ReactNode } from "react";
import { useLocation } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import { Topbar } from "./Topbar";
import { Sidebar } from "./Sidebar";
import { canAccessRole, designRoleOf, useAuth, type Role } from "../../lib/auth";
@ -78,6 +78,79 @@ export function AppShell({ children, className, contextLabel, hideNav, bleed, wi
return () => window.removeEventListener("pageshow", handlePageShow);
}, []);
const navigate = useNavigate();
// ── 전역 단축키 핸들러 ──
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
if (e.repeat) return;
const target = e.target instanceof HTMLElement ? e.target : null;
const isInput = target?.closest("input, textarea, select, [contenteditable='true']");
const isAlt = e.altKey && !e.ctrlKey && !e.metaKey;
const isCtrl = (e.ctrlKey || e.metaKey) && !e.altKey;
// Ctrl+W 또는 Escape: 활성 모달 닫기
if ((isCtrl && e.key.toLowerCase() === "w") || e.key === "Escape") {
const openModalCloseBtn = document.querySelector<HTMLButtonElement>(
"[role='dialog'] button:has(svg), [role='dialog'] .sx-panel-close-btn, .vg-dialog__close, [role='dialog'] button[aria-label*='닫기']"
);
if (openModalCloseBtn) {
e.preventDefault();
openModalCloseBtn.click();
return;
}
}
// Alt+H: 홈으로 이동
if (isAlt && e.key.toLowerCase() === "h") {
e.preventDefault();
const homePath = user?.role === "admin" ? "/admin" : user?.role === "teacher" ? "/professor" : "/learn";
navigate(homePath);
return;
}
// Alt+N 또는 Ctrl+N 또는 Ctrl+T (입력창 밖): 새 회기 시작 / 페르소나 선택
if ((isAlt && e.key.toLowerCase() === "n") || (!isInput && isCtrl && (e.key.toLowerCase() === "n" || e.key.toLowerCase() === "t"))) {
e.preventDefault();
navigate("/learn");
return;
}
// Alt+K: 학습 기록으로 이동
if (isAlt && e.key.toLowerCase() === "k") {
e.preventDefault();
navigate("/learn/history");
return;
}
// Alt+S: 설정 화면으로 이동
if (isAlt && e.key.toLowerCase() === "s") {
e.preventDefault();
navigate("/settings");
return;
}
// Alt+T: 다크/라이트 테마 전환
if (isAlt && e.key.toLowerCase() === "t") {
e.preventDefault();
const themeBtn = document.querySelector<HTMLButtonElement>(".vg-topbar__theme-btn, [data-testid='theme-toggle']");
if (themeBtn) {
themeBtn.click();
} else {
const currentTheme = document.documentElement.getAttribute("data-theme") || "light";
const nextTheme = currentTheme === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", nextTheme);
localStorage.setItem("vignette_theme", nextTheme);
}
return;
}
};
window.addEventListener("keydown", handleGlobalKeyDown);
return () => window.removeEventListener("keydown", handleGlobalKeyDown);
}, [navigate, user]);
return (
<div
className={[

View file

@ -85,7 +85,7 @@ export function Sidebar({ role, showAdminEntry = false, groupLabel = "메뉴" }:
<span className="vg-nav__ic">
<Icon name={item.icon} size={18} strokeWidth={1.75} />
</span>
<span>{item.label}</span>
<span className="vg-nav__txt">{item.label}</span>
</NavLink>
))}

View file

@ -104,6 +104,10 @@
font-weight: 650;
text-decoration: none;
white-space: nowrap;
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.vg-topbar__switch-link:active {
transform: scale(0.95);
}
.vg-topbar__switch-link:hover,
.vg-topbar__switch-link.is-active {
@ -125,9 +129,13 @@
color: var(--text-body);
cursor: pointer;
transition:
transform var(--dur-fast) var(--ease-spring),
background var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out);
}
.vg-iconbtn:active {
transform: scale(0.92);
}
.vg-iconbtn:hover {
background: var(--bg-surface-2);
color: var(--text-strong);
@ -324,33 +332,46 @@
}
@media (max-width: 720px) {
html {
scroll-padding-top: calc(var(--topbar-h) + 68px);
scroll-padding-bottom: var(--sp-6);
scroll-padding-top: calc(var(--topbar-h) + var(--sp-2));
scroll-padding-bottom: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--sp-4));
}
.vg-shell {
height: 100dvh;
}
.vg-shell__body {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 58px minmax(0, 1fr);
background-image: none; /* 네비가 상단 가로바로 전환 — 세로 구분선 제거 */
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
position: relative;
background-image: none; /* 네비가 하단바로 전환 — 세로 구분선 제거 */
}
.vg-shell__body--bare {
display: block;
display: flex;
}
.vg-nav {
position: relative;
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: auto;
z-index: 29;
height: 58px;
padding: 7px max(12px, env(safe-area-inset-left)) 7px max(12px, env(safe-area-inset-right));
z-index: 40;
height: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom));
padding: 6px max(10px, env(safe-area-inset-left)) max(6px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-right));
border-right: 0;
border-top: 0;
border-bottom: 1px solid var(--hair);
border-top: 1px solid var(--hair);
border-bottom: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 8px;
justify-content: space-around;
gap: 4px;
background: var(--bg-surface);
box-shadow: var(--shadow-island);
overflow-x: auto;
overflow-y: hidden;
-webkit-backdrop-filter: var(--glass-blur);
backdrop-filter: var(--glass-blur);
}
.vg-nav__label,
.vg-nav__spacer,
@ -358,16 +379,43 @@
display: none;
}
.vg-nav__item {
min-width: 112px;
height: 44px;
flex: 1 1 0;
min-width: 0;
max-width: 96px;
height: 48px;
min-height: 48px;
padding: 3px 2px;
flex-direction: column;
justify-content: center;
padding: 0 14px;
gap: 8px;
white-space: nowrap;
align-items: center;
gap: 2px;
border-radius: var(--radius);
text-align: center;
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-fast) var(--ease-out);
}
.vg-nav__item:active {
transform: scale(0.93);
}
.vg-nav__item .vg-nav__ic {
display: flex;
align-items: center;
justify-content: center;
}
.vg-nav__item .vg-nav__ic svg {
width: 20px;
height: 20px;
}
.vg-nav__item .vg-nav__txt,
.vg-nav__item span:not(.vg-nav__ic) {
display: inline;
font-size: 13px;
display: block;
font-size: 11px;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.01em;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vg-topbar__role {
display: none;
@ -376,50 +424,47 @@
display: none;
}
.vg-topbar {
padding: 0 4px;
gap: 4px;
height: 50px;
min-height: 50px;
padding: 0 var(--sp-3);
gap: var(--sp-2);
}
.vg-topbar__brand {
width: 44px;
height: 44px;
flex: 0 0 44px;
justify-content: center;
min-height: 44px;
gap: 8px;
}
.vg-topbar__mark {
width: 24px;
height: 24px;
}
.vg-topbar__wm {
font-size: 16px;
}
.vg-topbar__wm,
.vg-topbar__user {
display: none;
}
.vg-topbar__actions {
flex: 1 1 auto;
flex: 0 0 auto;
gap: 4px;
}
.vg-topbar__switch {
flex: 1 1 auto;
height: 44px;
padding: 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.vg-topbar__switch::-webkit-scrollbar {
display: none;
}
.vg-topbar__switch-link span {
display: none;
height: 38px;
padding: 2px;
}
.vg-topbar__switch-link {
width: 44px;
height: 44px;
flex: 0 0 44px;
padding: 0;
min-height: 34px;
padding: 0 8px;
font-size: 11.5px;
}
.vg-topbar__switch-link span {
display: inline;
}
.vg-iconbtn {
width: 44px;
height: 44px;
flex: 0 0 44px;
width: 40px;
height: 40px;
}
.vg-main {
padding: var(--sp-5) var(--sp-4) var(--sp-7);
padding: var(--sp-4) var(--sp-4) calc(var(--bottom-bar-h) + env(safe-area-inset-bottom) + var(--sp-6));
}
.vg-main--bleed {
padding: 0;
@ -584,9 +629,8 @@ body[data-role] .vg-shell--botanical .vg-nav__foot {
@media (max-width: 720px) {
body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 58px minmax(0, 1fr);
display: flex;
flex-direction: column;
background-image:
radial-gradient(circle at 74% 12%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 22rem),
radial-gradient(circle at 18% 82%, color-mix(in srgb, var(--botanical-gold) 14%, transparent), transparent 28rem),
@ -596,46 +640,88 @@ body[data-role] .vg-shell--botanical .vg-nav__foot {
background-size: 100% 34rem, 100% 38rem, 430px auto;
}
body[data-role] .vg-shell--botanical .vg-nav {
position: relative;
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: auto;
z-index: 29;
height: 58px;
padding: 7px max(12px, env(safe-area-inset-left)) 7px max(12px, env(safe-area-inset-right));
border-bottom: 1px solid var(--botanical-hair);
background: color-mix(in srgb, var(--bg-surface) 72%, transparent);
z-index: 40;
height: calc(var(--bottom-bar-h) + env(safe-area-inset-bottom));
padding: 6px max(10px, env(safe-area-inset-left)) max(6px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-right));
border-top: 1px solid var(--botanical-hair);
border-bottom: 0;
border-right: 0;
border-left: 0;
background: color-mix(in srgb, var(--bg-surface) 86%, transparent);
box-shadow: var(--shadow-island);
-webkit-backdrop-filter: var(--glass-blur);
backdrop-filter: var(--glass-blur);
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-around;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
}
body[data-role] .vg-shell--botanical .vg-nav__item,
body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
min-width: 112px;
height: 44px;
padding: 0 14px;
gap: 8px;
flex: 1 1 0;
min-width: 0;
max-width: 96px;
height: 48px;
min-height: 48px;
padding: 3px 2px;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 2px;
border-radius: var(--radius);
text-align: center;
border: 1px solid transparent;
}
body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
padding-right: 2px;
background: linear-gradient(
112deg,
color-mix(in srgb, var(--bg-surface) 92%, var(--botanical-gold)),
color-mix(in srgb, var(--bg-surface-2) 80%, transparent)
);
border-color: color-mix(in srgb, var(--botanical-gold) 34%, var(--border-subtle));
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg-surface) 84%, transparent);
}
body[data-role] .vg-shell--botanical .vg-nav__item.is-active::after {
display: none;
}
body[data-role] .vg-shell--botanical .vg-nav__item .vg-nav__ic svg {
width: 20px;
height: 20px;
}
body[data-role] .vg-shell--botanical .vg-nav__item .vg-nav__txt,
body[data-role] .vg-shell--botanical .vg-nav__item span:not(.vg-nav__ic) {
display: block;
font-size: 11px;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.01em;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 학습자는 항목이 고정이므로 숨은 가로 탐색보다 화면 4등분이 예측 가능하다.
교수자·관리자처럼 항목 수가 많은 역할은 기존 가로 스크롤 계약을 유지한다. */
body[data-role="learner"] .vg-shell--botanical .vg-nav {
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
gap:4px;
overflow-x:visible;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
overflow-x: visible;
}
body[data-role="learner"] .vg-shell--botanical .vg-nav__item,
body[data-role="learner"] .vg-shell--botanical .vg-nav__item.is-active {
width:100%;
min-width:0;
height:44px;
padding:0 2px;
gap:3px;
font-size:12px;
}
body[data-role="learner"] .vg-shell--botanical .vg-nav__item span:not(.vg-nav__ic) {
font-size:12px;
}
body[data-role="learner"] .vg-shell--botanical .vg-nav__ic svg {
width:16px;
height:16px;
width: 100%;
max-width: none;
min-width: 0;
height: 48px;
padding: 3px 2px;
}
}

View file

@ -39,6 +39,7 @@ export type IconName =
| "school"
| "sun"
| "moon"
| "copy"
| "refresh";
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
@ -227,6 +228,12 @@ const PATHS: Record<IconName, ReactNode> = {
</>
),
moon: <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />,
copy: (
<>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</>
),
};
export function Icon({

View file

@ -57,13 +57,14 @@
transition:
background var(--dur-base) var(--ease-out),
border-color var(--dur-base) var(--ease-out),
transform var(--dur-fast) var(--ease-out);
transform var(--dur-fast) var(--ease-spring),
box-shadow var(--dur-fast) var(--ease-out);
cursor: pointer;
white-space: nowrap;
user-select: none;
}
.vg-btn:active {
transform: translateY(0.5px);
transform: translateY(1px) scale(0.98);
}
.vg-btn:disabled {
opacity: 0.5;

View file

@ -6108,6 +6108,8 @@ export interface components {
client_reply?: string | null;
/** Learner Text */
learner_text: string;
/** Question */
question?: string | null;
/** Turn Seq */
turn_seq?: number | null;
};

View file

@ -162,6 +162,14 @@ const THEORY_MODE_OPTIONS: {
];
const DEFAULT_COACH_QUOTA: LiveCoachQuota = { remaining: 3, max: 3 };
interface CoachChatMessage {
id: string;
sender: "learner" | "coach";
text: string;
suggestion?: LiveCoachSuggestion;
createdAt: number;
}
function preferredTheoryMode(summary: PersonaSummary | null): TheoryMode {
const firstSupported = summary?.theory_target.find(
(target): target is TheoryMode =>
@ -492,7 +500,10 @@ export default function Session() {
const goalNudgeShownRef = useRef(false);
const [timeUp, setTimeUp] = useState(false);
// ── 패널 컬랩싱(과밀 완화) — 접힌 패널은 상황에 맞춰 넛지/강제 오픈 ──
// ── 패널 컬랩싱(과밀 완화 및 몰입 확장) — 접힌 패널은 상황에 맞춰 넛지/강제 오픈 ──
const [leftPanelCollapsed, setLeftPanelCollapsed] = useState(false);
const [rightPanelCollapsed, setRightPanelCollapsed] = useState(false);
const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
const [ctxCollapsed, setCtxCollapsed] = useState(true);
const [metersCollapsed, setMetersCollapsed] = useState(false);
const [safetyCollapsed, setSafetyCollapsed] = useState(true);
@ -525,6 +536,15 @@ export default function Session() {
const coachCreditSeenRef = useRef<Set<string>>(new Set());
const coachCreditPulseTimerRef = useRef<number | null>(null);
// ── AI 코칭 버블 팝업 상태 ──
const [isCoachBubbleOpen, setIsCoachBubbleOpen] = useState(false);
const [coachChatInput, setCoachChatInput] = useState("");
const [coachChatLoading, setCoachChatLoading] = useState(false);
const [coachChatMessages, setCoachChatMessages] = useState<CoachChatMessage[]>([]);
const coachBubbleAnchorRef = useRef<HTMLDivElement>(null);
const coachChatInputRef = useRef<HTMLInputElement>(null);
const coachChatScrollRef = useRef<HTMLDivElement>(null);
activeVoiceSessionRef.current = liveSessionId;
const failPendingVoiceLearnerTurn = useCallback(() => {
@ -610,6 +630,9 @@ export default function Session() {
coachCreditSeenRef.current = new Set();
setCoachHistoryOpen(false);
setCoachHistoryTurnSeq(null);
setIsCoachBubbleOpen(false);
setCoachChatMessages([]);
setCoachChatInput("");
}, [navigate, routeIsSessionId, routeParam]);
useEffect(() => {
@ -1019,6 +1042,158 @@ export default function Session() {
void requestLiveCoach(payload);
}, [feedbackMode, pendingCoachTurn, requestLiveCoach]);
// ── AI 코칭 버블 팝업 제어 및 질의응답 ──
useEffect(() => {
if (!isCoachBubbleOpen) return;
const focusTimer = window.setTimeout(() => {
coachChatInputRef.current?.focus();
}, 60);
const handlePointerDownOutside = (e: MouseEvent) => {
if (
coachBubbleAnchorRef.current &&
!coachBubbleAnchorRef.current.contains(e.target as Node)
) {
setIsCoachBubbleOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsCoachBubbleOpen(false);
}
};
document.addEventListener("mousedown", handlePointerDownOutside);
document.addEventListener("keydown", handleKeyDown);
return () => {
window.clearTimeout(focusTimer);
document.removeEventListener("mousedown", handlePointerDownOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isCoachBubbleOpen]);
useEffect(() => {
if (isCoachBubbleOpen && coachChatScrollRef.current) {
coachChatScrollRef.current.scrollTop = coachChatScrollRef.current.scrollHeight;
}
}, [coachChatMessages, coachChatLoading, isCoachBubbleOpen]);
const handleSendCoachBubbleQuery = useCallback(
async (queryText: string) => {
if (!liveSessionId || !queryText.trim()) return;
if (coachQuota.remaining <= 0) {
setCoachChatMessages((prev) => [
...prev,
{
id: randomUuid(),
sender: "coach",
text: "코칭 기회를 모두 사용했습니다. 좋은 발화로 내담자가 열리거나 회기가 진행되면 충전됩니다.",
createdAt: Date.now(),
},
]);
return;
}
const userMsgId = randomUuid();
setCoachChatMessages((prev) => [
...prev,
{
id: userMsgId,
sender: "learner",
text: queryText,
createdAt: Date.now(),
},
]);
setCoachChatLoading(true);
const lastLearnerUtterance = [...utterances]
.reverse()
.find((u) => u.speaker === "learner" && !u.partial && !u.failed);
const lastClientUtterance = [...utterances]
.reverse()
.find((u) => u.speaker === "client" && !u.partial && !u.failed);
const learnerTextToSend =
composeText.trim() ||
lastLearnerUtterance?.text ||
"상담 진행 및 다음 개입 방향 조언 요청";
const clientReplyToSend = lastClientUtterance?.text ?? null;
const turnSeqToSend = lastLearnerUtterance?.turnSeq;
try {
const suggestion = await sessionApi.liveCoach(liveSessionId, {
learner_text: learnerTextToSend,
question: queryText,
client_reply: clientReplyToSend,
turn_seq: turnSeqToSend,
});
setCoachSuggestion(suggestion);
setCoachPersistenceSource(suggestion.persistence_source ?? null);
if (suggestion.quota) {
setCoachQuota(suggestion.quota);
}
if (suggestion.credit_events?.length) {
applyCoachCreditEvents(suggestion.credit_events, true);
}
pushSignal(suggestion.tone, suggestion.title);
void refreshCoachHistory(liveSessionId, { animateCredits: false, warnOnFailure: true });
setCoachChatMessages((prev) => [
...prev,
{
id: randomUuid(),
sender: "coach",
text: suggestion.message,
suggestion,
createdAt: Date.now(),
},
]);
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setCoachQuota((current) => ({ ...current, remaining: 0 }));
setCoachChatMessages((prev) => [
...prev,
{
id: randomUuid(),
sender: "coach",
text: "코칭 기회를 모두 사용했습니다. 좋은 발화로 내담자가 열리면 다시 1개가 충전됩니다.",
createdAt: Date.now(),
},
]);
} else {
setCoachChatMessages((prev) => [
...prev,
{
id: randomUuid(),
sender: "coach",
text: "코칭 조언을 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.",
createdAt: Date.now(),
},
]);
}
} finally {
setCoachChatLoading(false);
}
},
[
applyCoachCreditEvents,
coachQuota.remaining,
composeText,
liveSessionId,
pushSignal,
refreshCoachHistory,
utterances,
],
);
const applySuggestionToComposer = useCallback((text: string) => {
setComposeText(text);
}, []);
useEffect(() => {
if (!routeIsSessionId) return;
// 이 화면에서 방금 시작한 회기의 URL 전환은 재개(resume)가 아니다 — 서버 스냅샷(0턴)
@ -2155,28 +2330,174 @@ export default function Session() {
});
}, [sessionEnded, shutdownVoice, voiceStatus]);
// ── 키보드 단축키: Alt+M=마이크, P=일시정지. 입력 중엔 무시 ──
// ── 키보드 단축키 전수 등록 ──
useEffect(() => {
if (!started) return;
const onKey = (e: KeyboardEvent) => {
if (e.repeat) return;
if (e.altKey && !e.ctrlKey && !e.metaKey && e.key.toLowerCase() === "m") {
// 1. 전역 모달 닫기: Escape 또는 Ctrl+W (모달이 열려있을 때)
if (e.key === "Escape" || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "w")) {
if (shortcutHelpOpen) {
e.preventDefault();
setShortcutHelpOpen(false);
return;
}
if (coachEvidenceOpen) {
e.preventDefault();
setCoachEvidenceOpen(false);
return;
}
if (coachHistoryOpen) {
e.preventDefault();
setCoachHistoryOpen(false);
return;
}
if (endDialogOpen && !ending) {
e.preventDefault();
setEndDialogOpen(false);
return;
}
}
// 2. 단축키 도움말: ? (입력창 밖) 또는 Ctrl+/ or Alt+/
if (
((e.ctrlKey || e.metaKey || e.altKey) && e.key === "/") ||
(e.key === "?" && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement))
) {
e.preventDefault();
setShortcutHelpOpen((prev) => !prev);
return;
}
// 시작 전 프리플라이트 단축키
if (!started) {
if ((e.ctrlKey || e.metaKey || e.altKey) && e.key === "Enter") {
e.preventDefault();
if (selectedGoals.length > 0 && !starting) {
void handleStart();
}
return;
}
return;
}
// 세션 진행 중 단축키
const isAlt = e.altKey && !e.ctrlKey && !e.metaKey;
const isCtrl = (e.ctrlKey || e.metaKey) && !e.altKey;
const target = e.target instanceof HTMLElement ? e.target : null;
const isInput = target?.closest("input, textarea, select, [contenteditable='true']");
// Alt+M: 마이크 토글
if (isAlt && e.key.toLowerCase() === "m") {
e.preventDefault();
void toggleMic();
return;
}
const target = e.target instanceof HTMLElement ? e.target : null;
const interactive = target?.closest(
"button, a, input, textarea, select, [contenteditable='true'], [role='button'], [role='link']",
);
if (interactive) return;
if (e.key === "p" || e.key === "P") {
// Alt+[ 또는 Ctrl+[: 좌측 패널 토글
if ((isAlt || isCtrl) && (e.key === "[" || e.code === "BracketLeft")) {
e.preventDefault();
setLeftPanelCollapsed((prev) => !prev);
return;
}
// Alt+] 또는 Ctrl+]: 우측 패널 토글
if ((isAlt || isCtrl) && (e.key === "]" || e.code === "BracketRight")) {
e.preventDefault();
setRightPanelCollapsed((prev) => !prev);
return;
}
// Alt+\ 또는 Ctrl+\: 완전 몰입 모드 토글 (양쪽 패널 동시 접기/펼치기)
if ((isAlt || isCtrl) && (e.key === "\\" || e.code === "Backslash")) {
e.preventDefault();
const bothCollapsed = leftPanelCollapsed && rightPanelCollapsed;
setLeftPanelCollapsed(!bothCollapsed);
setRightPanelCollapsed(!bothCollapsed);
return;
}
// Alt+1 / Alt+2 / Alt+3: 피드백 모드 전환
if (isAlt && e.key === "1") {
e.preventDefault();
setFeedbackMode("immersive");
return;
}
if (isAlt && e.key === "2") {
e.preventDefault();
setFeedbackMode("ambient");
return;
}
if (isAlt && e.key === "3") {
e.preventDefault();
setFeedbackMode("coached");
return;
}
// Alt+Q: 회기 종료 확인
if (isAlt && (e.key.toLowerCase() === "q" || e.key.toLowerCase() === "w")) {
e.preventDefault();
if (!sessionEnded) setEndDialogOpen(true);
return;
}
// Alt+J 또는 Alt+Down: 최신 발화로 점프
if (isAlt && (e.key.toLowerCase() === "j" || e.key === "ArrowDown")) {
e.preventDefault();
jumpToLatest();
return;
}
// Alt+C: AI 코칭 이력
if (isAlt && e.key.toLowerCase() === "c") {
e.preventDefault();
openCoachHistory(null);
return;
}
// Alt+E: AI 코칭 근거
if (isAlt && e.key.toLowerCase() === "e") {
e.preventDefault();
setCoachEvidenceOpen(true);
return;
}
// Alt+P: 일시정지 토글
if (isAlt && e.key.toLowerCase() === "p") {
e.preventDefault();
togglePause();
return;
}
// 입력창 밖 단축키
if (!isInput) {
if (e.key === "p" || e.key === "P" || e.key === " ") {
if (e.key === " ") e.preventDefault();
togglePause();
return;
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [started, toggleMic, togglePause]);
}, [
started,
selectedGoals.length,
starting,
handleStart,
toggleMic,
togglePause,
leftPanelCollapsed,
rightPanelCollapsed,
sessionEnded,
jumpToLatest,
openCoachHistory,
shortcutHelpOpen,
coachEvidenceOpen,
coachHistoryOpen,
endDialogOpen,
ending,
]);
useEffect(() => {
voiceCaptureMountedRef.current = true;
@ -2544,29 +2865,57 @@ export default function Session() {
</header>
{started ? (
<nav className="sx-sessionbar" aria-label="세션 이동">
<button
type="button"
className="sx-sessionbar__back"
onClick={() => navigate("/learn/history")}
>
<Icon name="chevron-left" size={16} />
</button>
<div className="sx-sessionbar__meta">
<span className={"sx-sessionbar__dot" + (sessionEnded ? " is-ended" : "")} />
<span>
<b>{resumedSessionLoaded ? "이전 회기 기록을 불러왔습니다." : sessionStatusLabel}</b>
{/* D3 " · · 0:03" · 2
"서연 · 라포 단계" + "0:03" . */}
<small>
<span>
{clientName} · {stage}
</span>
<span>{elapsedLabel}</span>
</small>
</span>
<nav className="sx-sessionbar" aria-label="세션 이동 및 패널 제어">
<div className="sx-sessionbar__left">
<button
type="button"
className="sx-sessionbar__back"
onClick={() => navigate("/learn/history")}
>
<Icon name="chevron-left" size={16} />
</button>
<div className="sx-sessionbar__meta">
<span className={"sx-sessionbar__dot" + (sessionEnded ? " is-ended" : "")} />
<span>
<b>{resumedSessionLoaded ? "이전 회기 기록을 불러왔습니다." : sessionStatusLabel}</b>
{/* D3 " · · 0:03" · 2
"서연 · 라포 단계" + "0:03" . */}
<small>
<span>
{clientName} · {stage}
</span>
<span>{elapsedLabel}</span>
</small>
</span>
</div>
</div>
<div className="sx-sessionbar__toggles">
<button
type="button"
className={"sx-sessionbar__toggle-btn" + (leftPanelCollapsed ? " is-collapsed" : "")}
onClick={() => setLeftPanelCollapsed((prev) => !prev)}
title={leftPanelCollapsed ? "회기 진행 및 컨텍스트 패널 펼치기" : "회기 진행 및 컨텍스트 패널 접기"}
aria-label={leftPanelCollapsed ? "회기 진행 및 컨텍스트 패널 펼치기" : "회기 진행 및 컨텍스트 패널 접기"}
aria-pressed={!leftPanelCollapsed}
>
<Icon name="session" size={15} />
<span>{leftPanelCollapsed ? "진행 열기" : "진행 접기"}</span>
</button>
<button
type="button"
className={"sx-sessionbar__toggle-btn" + (rightPanelCollapsed || feedbackMode === "immersive" ? " is-collapsed" : "")}
onClick={() => setRightPanelCollapsed((prev) => !prev)}
title={rightPanelCollapsed ? "AI 코칭 패널 펼치기" : "AI 코칭 패널 접기"}
aria-label={rightPanelCollapsed ? "AI 코칭 패널 펼치기" : "AI 코칭 패널 접기"}
aria-pressed={!rightPanelCollapsed && feedbackMode !== "immersive"}
>
<Icon name="spark" size={15} />
<span>{rightPanelCollapsed || feedbackMode === "immersive" ? "코칭 열기" : "코칭 접기"}</span>
</button>
</div>
<div className="sx-sessionbar__actions">
<button type="button" onClick={() => navigate("/learn")}>
<Icon name="home" size={15} />
@ -2877,8 +3226,15 @@ export default function Session() {
/>
) : null}
{/* ── 3-region 그리드 ── */}
<div className="sx-grid">
{/* ── 3-region 가변 그리드 ── */}
<div
className={
"sx-grid" +
(leftPanelCollapsed ? " is-left-collapsed" : "") +
(rightPanelCollapsed || feedbackMode === "immersive" ? " is-right-collapsed" : "") +
(leftPanelCollapsed && (rightPanelCollapsed || feedbackMode === "immersive") ? " is-focus-mode" : "")
}
>
<section className={surfaceClassName("sx-mobile-context", { variant: "inset" })} aria-label="현재 회기 요약">
<div className="sx-mobile-context__row">
<span>
@ -2945,12 +3301,39 @@ export default function Session() {
</section>
{/* ── LEFT ── */}
<div className="sx-col sx-col-left">
{/* 회기 단계 세로 트랙 */}
<section className={surfaceClassName("sx-panel sx-track")}>
<div className="sx-track__head">
<Kicker> </Kicker>
</div>
{leftPanelCollapsed ? (
<aside className="sx-col-mini-rail sx-col-mini-rail--left" aria-label="회기 진행 및 컨텍스트 접힘">
<button
type="button"
className="sx-mini-rail-btn"
onClick={() => setLeftPanelCollapsed(false)}
title="회기 진행 및 컨텍스트 패널 펼치기"
aria-label="회기 진행 및 컨텍스트 패널 펼치기"
>
<Icon name="session" size={16} />
<span className="sx-mini-rail-txt"> · {stage}</span>
<Icon name="chevron-right" size={14} />
</button>
</aside>
) : (
<div className="sx-col sx-col-left">
{/* 회기 단계 세로 트랙 */}
<section className={surfaceClassName("sx-panel sx-track")}>
<div className="sx-track__head">
<div className="sx-panel-head-row">
<Kicker> </Kicker>
<button
type="button"
className="sx-panel-close-btn"
onClick={() => setLeftPanelCollapsed(true)}
title="좌측 패널 접기"
aria-label="좌측 패널 접기"
>
<Icon name="chevron-left" size={14} />
<span></span>
</button>
</div>
</div>
{SESSION_PHASES.map((p, i) => {
const cls =
i < stageIdx ? "is-done" : i === stageIdx ? "is-cur" : "";
@ -3082,6 +3465,7 @@ export default function Session() {
) : null}
</section>
</div>
)}
{/* ── CENTER ── */}
<div className="sx-col sx-col-center">
@ -3310,25 +3694,273 @@ export default function Session() {
aria-label="학습자 발화 입력"
/>
</div>
<Button
onClick={() => void handleSend()}
disabled={!composeText.trim() || textTurnBlocked || paused || sessionEnded || alliancePreGateBlocked}
trailing={<Icon name="arrow-up" size={15} />}
>
</Button>
<div className="sx-compose__actions">
<div className="sx-coach-anchor" ref={coachBubbleAnchorRef}>
<button
type="button"
className={`sx-coach-trigger-btn ${isCoachBubbleOpen ? "is-active" : ""}`}
onClick={() => setIsCoachBubbleOpen((prev) => !prev)}
disabled={sessionEnded || paused}
aria-label={`AI 코칭, 남은 기회 ${coachQuotaRemaining}`}
title="AI 코칭 받기"
>
<Icon name="spark" size={15} />
<span>AI </span>
<span
className={
"sx-coach-trigger-btn__badge" +
(coachQuotaRemaining <= 0 ? " is-empty" : "")
}
>
{coachQuotaRemaining}
</span>
</button>
{isCoachBubbleOpen ? (
<div
className="sx-coach-bubble-popup"
role="dialog"
aria-label="AI 슈퍼바이저 코칭 버블"
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<div className="sx-coach-bubble-popup__head">
<div className="sx-coach-bubble-popup__title">
<Icon name="spark" size={14} />
<strong>AI </strong>
</div>
<div className="sx-coach-bubble-popup__head-right">
<span
className={
"sx-coach-bubble-popup__quota" +
(coachQuotaRemaining <= 0 ? " is-empty" : "")
}
>
{coachQuotaRemaining}/{coachQuotaMax}
</span>
<button
type="button"
className="sx-coach-bubble-popup__close"
onClick={() => setIsCoachBubbleOpen(false)}
aria-label="코칭 팝업 닫기"
>
<Icon name="x" size={13} />
</button>
</div>
</div>
<div
className="sx-coach-bubble-popup__body"
ref={coachChatScrollRef}
>
<div className="sx-coach-msg sx-coach-msg--welcome">
<div className="sx-coach-msg__avatar">
<Icon name="spark" size={13} />
</div>
<div className="sx-coach-msg__content">
<p>
.
</p>
{coachQuotaRemaining > 0 && coachChatMessages.length === 0 ? (
<div className="sx-coach-bubble-popup__chips">
<button
type="button"
className="sx-coach-chip"
onClick={() => void handleSendCoachBubbleQuery("다음 추천 발화와 방향을 알려줘")}
disabled={coachChatLoading}
>
💡
</button>
<button
type="button"
className="sx-coach-chip"
onClick={() => void handleSendCoachBubbleQuery("내담자 저항을 낮추는 공감 기법을 알려줘")}
disabled={coachChatLoading}
>
🤝
</button>
<button
type="button"
className="sx-coach-chip"
onClick={() => void handleSendCoachBubbleQuery("호소 문제를 더 깊이 탐색할 수 있는 질문을 제안해줘")}
disabled={coachChatLoading}
>
🔍
</button>
</div>
) : null}
</div>
</div>
{coachChatMessages.map((msg) => (
<div
key={msg.id}
className={`sx-coach-msg sx-coach-msg--${msg.sender}`}
>
{msg.sender === "coach" ? (
<div className="sx-coach-msg__avatar">
<Icon name="spark" size={13} />
</div>
) : null}
<div className="sx-coach-msg__content">
{msg.suggestion ? (
<div className="sx-coach-msg__suggestion">
<strong className="sx-coach-msg__title">
{msg.suggestion.title}
</strong>
<p className="sx-coach-msg__desc">
{msg.suggestion.message}
</p>
{msg.suggestion.next_utterance ? (
<div className="sx-coach-msg__next-utterance">
<span className="sx-coach-msg__next-label">
</span>
<blockquote>{msg.suggestion.next_utterance}</blockquote>
<button
type="button"
className="sx-coach-msg__apply-btn"
onClick={() =>
applySuggestionToComposer(
msg.suggestion!.next_utterance!,
)
}
title="이 문장을 발화 입력창에 넣기"
>
<Icon name="copy" size={12} />
<span> </span>
</button>
</div>
) : null}
{msg.suggestion.rationale ? (
<small className="sx-coach-msg__rationale">
{msg.suggestion.rationale}
</small>
) : null}
</div>
) : (
<p>{msg.text}</p>
)}
</div>
</div>
))}
{coachChatLoading ? (
<div className="sx-coach-msg sx-coach-msg--coach sx-coach-msg--loading">
<div className="sx-coach-msg__avatar is-pulsing">
<Icon name="spark" size={13} />
</div>
<div className="sx-coach-msg__content">
<div className="sx-coach-typing-indicator">
<span />
<span />
<span />
</div>
<small>AI ...</small>
</div>
</div>
) : null}
</div>
<div className="sx-coach-bubble-popup__footer">
{coachQuotaRemaining <= 0 ? (
<div className="sx-coach-bubble-popup__exhausted">
. .
</div>
) : (
<form
className="sx-coach-bubble-popup__composer"
onSubmit={(e) => {
e.preventDefault();
if (!coachChatInput.trim() || coachChatLoading) return;
const text = coachChatInput.trim();
setCoachChatInput("");
void handleSendCoachBubbleQuery(text);
}}
>
<input
ref={coachChatInputRef}
type="text"
value={coachChatInput}
onChange={(e) => setCoachChatInput(e.target.value)}
placeholder="코치에게 조언을 구해보세요... (Enter 전송)"
disabled={coachChatLoading || sessionEnded || paused}
aria-label="AI 코칭 질문 입력"
/>
<button
type="submit"
disabled={
!coachChatInput.trim() ||
coachChatLoading ||
sessionEnded ||
paused
}
aria-label="코칭 질문 전송"
title="질문 전송"
>
<Icon name="arrow-up" size={13} />
</button>
</form>
)}
</div>
</div>
) : null}
</div>
<Button
onClick={() => void handleSend()}
disabled={
!composeText.trim() ||
textTurnBlocked ||
paused ||
sessionEnded ||
alliancePreGateBlocked
}
trailing={<Icon name="arrow-up" size={15} />}
>
</Button>
</div>
</div>
</section>
</div>
{/* ── RIGHT ── */}
<div className="sx-col sx-col-right">
{/* 라이브 신호 (몰입 모드면 숨김) */}
{feedbackMode !== "immersive" ? (
{rightPanelCollapsed ? (
<aside className="sx-col-mini-rail sx-col-mini-rail--right" aria-label="AI 코칭 및 신호 패널 접힘">
<button
type="button"
className="sx-mini-rail-btn"
onClick={() => {
setRightPanelCollapsed(false);
}}
title="AI 코칭 및 신호 패널 펼치기"
aria-label="AI 코칭 및 신호 패널 펼치기"
>
<Icon name="spark" size={16} />
<span className="sx-mini-rail-txt">AI · </span>
<Icon name="chevron-left" size={14} />
</button>
</aside>
) : (
<div className="sx-col sx-col-right">
{feedbackMode !== "immersive" ? (
<section className={surfaceClassName("sx-panel sx-signal")}>
<div className="sx-signal__head">
<Kicker dot={false}> </Kicker>
</div>
<div className="sx-signal__head">
<div className="sx-panel-head-row">
<Kicker dot={false}> </Kicker>
<button
type="button"
className="sx-panel-close-btn"
onClick={() => setRightPanelCollapsed(true)}
title="AI 코칭 패널 접기"
aria-label="AI 코칭 패널 접기"
>
<span></span>
<Icon name="chevron-right" size={14} />
</button>
</div>
</div>
{liveSignal ? (
<div
@ -3500,7 +4132,6 @@ export default function Session() {
) : (
<section className={surfaceClassName("sx-panel sx-signal")}>
<div className="sx-signal__head">
{/* D1 — 섹션 라벨 → dot 제거 */}
<Kicker dot={false}> </Kicker>
</div>
<div className="sx-signal__defer">
@ -3634,6 +4265,7 @@ export default function Session() {
) : null}
</section>
</div>
)}
</div>
{/* ── 하단 컨트롤 바 ── */}
@ -4121,6 +4753,125 @@ export default function Session() {
</div>
) : null}
{shortcutHelpOpen ? (
<div
className="sx-coach-modal-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setShortcutHelpOpen(false);
}}
>
<section
className="sx-coach-modal sx-shortcut-modal"
role="dialog"
aria-modal="true"
aria-labelledby="sx-shortcut-modal-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="sx-coach-modal__head">
<span className="sx-coach-modal__avatar" aria-hidden="true">
<Icon name="spark" size={20} />
</span>
<div>
<h2 id="sx-shortcut-modal-title"> </h2>
<p> .</p>
</div>
</div>
<div className="sx-coach-modal__body sx-shortcut-modal__body">
<div className="sx-shortcut-group">
<h3> </h3>
<dl className="sx-shortcut-list">
<div>
<dt><kbd>Alt</kbd>+<kbd>M</kbd></dt>
<dd> / </dd>
</div>
<div>
<dt><kbd>Alt</kbd>+<kbd>P</kbd> <kbd>Space</kbd></dt>
<dd> / </dd>
</div>
<div>
<dt><kbd>Enter</kbd></dt>
<dd> </dd>
</div>
<div>
<dt><kbd>Alt</kbd>+<kbd>J</kbd> <kbd>Alt</kbd>+<kbd></kbd></dt>
<dd> </dd>
</div>
</dl>
</div>
<div className="sx-shortcut-group">
<h3> & </h3>
<dl className="sx-shortcut-list">
<div>
<dt><kbd>Alt</kbd>+<kbd>[</kbd> <kbd>Ctrl</kbd>+<kbd>[</kbd></dt>
<dd> ( & ) /</dd>
</div>
<div>
<dt><kbd>Alt</kbd>+<kbd>]</kbd> <kbd>Ctrl</kbd>+<kbd>]</kbd></dt>
<dd> (AI & ) /</dd>
</div>
<div>
<dt><kbd>Alt</kbd>+<kbd>\</kbd> <kbd>Ctrl</kbd>+<kbd>\</kbd></dt>
<dd> ( )</dd>
</div>
<div>
<dt><kbd>Alt</kbd>+<kbd>1</kbd> / <kbd>2</kbd> / <kbd>3</kbd></dt>
<dd> / / </dd>
</div>
</dl>
</div>
<div className="sx-shortcut-group">
<h3>AI </h3>
<dl className="sx-shortcut-list">
<div>
<dt><kbd>Alt</kbd>+<kbd>C</kbd></dt>
<dd>AI </dd>
</div>
<div>
<dt><kbd>Alt</kbd>+<kbd>E</kbd></dt>
<dd> </dd>
</div>
</dl>
</div>
<div className="sx-shortcut-group">
<h3> & </h3>
<dl className="sx-shortcut-list">
<div>
<dt><kbd>Alt</kbd>+<kbd>Q</kbd> <kbd>Alt</kbd>+<kbd>W</kbd></dt>
<dd> </dd>
</div>
<div>
<dt><kbd>Ctrl</kbd>+<kbd>Enter</kbd></dt>
<dd> </dd>
</div>
<div>
<dt><kbd>?</kbd> <kbd>Ctrl</kbd>+<kbd>/</kbd></dt>
<dd> /</dd>
</div>
<div>
<dt><kbd>Escape</kbd> <kbd>Ctrl</kbd>+<kbd>W</kbd></dt>
<dd> / </dd>
</div>
</dl>
</div>
</div>
<div className="sx-coach-modal__actions">
<button
type="button"
onClick={() => setShortcutHelpOpen(false)}
>
(Esc)
</button>
</div>
</section>
</div>
) : null}
{/* 경과 시간(접근성 — 보조 표기. 화면 우상단 톱바는 셸 소관) */}
<span className="sr-only" aria-live="polite" style={{ position: "absolute", left: -9999 }}>
{timeUp && !sessionEnded ? "회기 시간 만료" : `경과 ${formatElapsed(elapsed)}`}

View file

@ -139,7 +139,7 @@
}
.axl__card {
--tone: #53616f;
--tone: var(--neutral-500);
min-width: 0;
min-height: 188px;
position: relative;
@ -163,23 +163,23 @@
}
.axl__card--positive {
--tone: #2f7d57;
--tone: var(--pos-solid);
}
.axl__card--negative {
--tone: #a84444;
--tone: var(--crit-solid);
}
.axl__card--defensive {
--tone: #7a5730;
--tone: var(--clay);
}
.axl__card--cognitive {
--tone: #386f9d;
--tone: var(--info-solid);
}
.axl__card--energy {
--tone: #7565a8;
--tone: var(--accent-bright);
}
.axl__card .vg-avatar {
@ -187,7 +187,7 @@
}
.axl__card .vg-avatar__stage {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bg-surface) 5%, transparent);
}
.axl__card-meta {

View file

@ -1,10 +1,10 @@
.ap{padding:32px;max-width:1100px;margin:0 auto;color:var(--text-body);}
.ap__head h1{font-size:var(--fs-h1);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:1px solid var(--border-subtle);border-radius:20px;background:linear-gradient(135deg,rgba(238,244,242,.96),rgba(246,240,236,.92));margin-bottom:32px;}
.ap__hero .vg-avatar__persona{color:#1c2a2a;}
.ap__hero .vg-avatar__state{color:#5a6663;}
.ap__hero .vg-avatar__state b{color:#1c2a2a;}
.ap__hero{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;padding:24px;border:1px solid var(--border-subtle);border-radius:20px;background:linear-gradient(135deg,color-mix(in srgb,var(--accent-tint) 96%,transparent),color-mix(in srgb,var(--bg-surface-2) 92%,transparent));margin-bottom:32px;}
.ap__hero .vg-avatar__persona{color:var(--text-strong);}
.ap__hero .vg-avatar__state{color:var(--text-muted);}
.ap__hero .vg-avatar__state b{color:var(--text-strong);}
.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);}

View file

@ -75,48 +75,35 @@
min-width:0;
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
gap:10px;
gap:12px;
}
/* 중간 폭 이하 전용 가로 탭 — 데스크톱에서는 숨김(리뷰 화면 sr-tabs와 동일 패턴) */
.lh-tabs{
display:none;
gap:6px;
gap:4px;
padding:4px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
border-radius:var(--radius-pill);
background:var(--bg-surface-2);
box-shadow:var(--shadow-sm);
}
.lh-tabs button{
position:relative;
flex:1 1 0;
min-width:0;
padding:9px 12px;
padding:8px 12px;
border:1px solid transparent;
border-radius:calc(var(--radius) - 2px);
border-radius:var(--radius-pill);
background:transparent;
color:var(--text-body);
font-size:var(--fs-sm);
font-weight:700;
font-weight:650;
cursor:pointer;
min-height:44px;
min-height:42px;
transition:all var(--dur-fast) var(--ease-spring);
}
/* D6( 구분 강화): 비활성 탭이 배경과 같아 "여러 개 중 하나" 읽혔다.
비활성 사이에만 세로 구분선을 넣는다. 활성 탭은 자체 배경/테두리로
이미 구분되므로 활성 탭에 붙는 구분선은 숨겨 이중 경계선을 막는다. */
.lh-tabs button + button::before{
content:"";
position:absolute;
left:-4px;
top:22%;
bottom:22%;
width:1px;
background:var(--border-strong);
opacity:.55;
pointer-events:none;
}
.lh-tabs button.is-active::before,
.lh-tabs button.is-active + button::before{
opacity:0;
.lh-tabs button:active{
transform:scale(0.96);
}
.lh-tabs button:hover{
color:var(--text-strong);
@ -131,6 +118,7 @@
border-color:var(--border-strong);
color:var(--text-strong);
box-shadow:var(--shadow-sm);
font-weight:750;
}
.lh-dashboard-side{
min-width:0;
@ -448,7 +436,7 @@
inset:0 auto 0 0;
width:var(--lh-stage-progress,0%);
border-radius:inherit;
background:linear-gradient(90deg,var(--accent),color-mix(in srgb,var(--accent) 72%,#f2c36b));
background:linear-gradient(90deg,var(--accent),color-mix(in srgb,var(--accent) 72%,var(--warn-solid)));
}
.lh-recap__quote{
min-width:0;
@ -1798,19 +1786,19 @@
[data-theme="dark"] .lh-compact-list li,
[data-theme="dark"] .lh-persona,
[data-theme="dark"] .lh-persona-progress__row{
border-color:rgba(203,227,220,.11);
background:rgba(255,255,255,.045);
border-color:color-mix(in srgb, var(--accent) 11%, transparent);
background:color-mix(in srgb, var(--text-strong) 4.5%, transparent);
}
[data-theme="dark"] .lh-recommend-card{
border-color:rgba(111,179,164,.22);
border-color:color-mix(in srgb, var(--accent) 22%, transparent);
background:
linear-gradient(135deg,rgba(111,179,164,.12),transparent 62%),
rgba(255,255,255,.052);
linear-gradient(135deg,color-mix(in srgb, var(--accent) 12%, transparent),transparent 62%),
color-mix(in srgb, var(--text-strong) 5.2%, transparent);
}
[data-theme="dark"] .lh-persona.is-selected,
[data-theme="dark"] .lh-persona-progress__row.is-selected{
border-color:rgba(111,179,164,.66);
background:rgba(111,179,164,.13);
border-color:color-mix(in srgb, var(--accent) 66%, transparent);
background:color-mix(in srgb, var(--accent) 13%, transparent);
}
@keyframes lhSkel{0%{background-position:120% 0}100%{background-position:-80% 0}}
/* 중간 (1181~1420px): 우측 사이드는 유지하되 리캡+코치 2열은 세로 스택으로.
@ -1939,9 +1927,14 @@
.lh-recap__facts{
grid-template-columns:repeat(2,minmax(0,1fr));
}
.lh-dashboard-status{
grid-template-columns:repeat(2,minmax(0,1fr));
gap:10px;
}
.lh-metric-card{
min-height:0;
padding:12px;
padding:14px;
border-radius:var(--radius);
}
.lh-personas{
display:flex;
@ -2175,7 +2168,7 @@
/* 학습 대시보드 보태니컬 시안 적용
카드 광원은 좌상단, 황금 기운은 헤어라인과 가장자리에서만 아주 옅게 사용한다. */
.vg-shell--learner-dashboard .lh-root {
--lh-gold: #b5975d;
--lh-gold: var(--clay-deep);
--lh-botanical-hair: color-mix(in srgb, var(--lh-gold) 34%, var(--border-subtle));
--lh-botanical-line: color-mix(in srgb, var(--lh-gold) 17%, var(--border-subtle));
gap: clamp(18px, 2vw, 28px);
@ -2187,7 +2180,7 @@
.vg-shell--learner-dashboard .lh-head__status {
border-color: var(--lh-botanical-hair);
background: var(--glass-surface-inset);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.62);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg-surface) 62%, transparent);
backdrop-filter: blur(10px);
}
.vg-shell--learner-dashboard .lh-dashboard-status {
@ -2261,7 +2254,7 @@
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(circle at 100% 0%, rgba(187, 156, 97, 0.06), transparent 22rem);
background: radial-gradient(circle at 100% 0%, color-mix(in srgb, var(--clay) 6%, transparent), transparent 22rem);
pointer-events: none;
}
.vg-shell--learner-dashboard .lh-work-cluster > * {
@ -2298,7 +2291,7 @@
background:var(--accent);
border-color:color-mix(in srgb,var(--accent-deep) 78%,var(--lh-gold));
color:var(--text-on-accent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg-surface) 28%, transparent);
}
.vg-shell--learner-dashboard .lh-coach-card__cta:hover:not(:disabled) {
background:var(--accent-deep);
@ -2311,12 +2304,12 @@
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-root {
--lh-gold: #bca579;
--lh-gold: var(--clay);
--lh-botanical-hair: color-mix(in srgb, var(--lh-gold) 27%, var(--border-subtle));
--lh-botanical-line: color-mix(in srgb, var(--lh-gold) 16%, var(--border-subtle));
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-head__status {
box-shadow: inset 0 1px 0 rgba(255, 245, 222, 0.055);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--clay-tint) 5.5%, transparent);
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-metric-card {
--lh-card-art: url("/design-elements/learner-dashboard/card-leaf-sprig.png");
@ -2336,8 +2329,8 @@
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-work-cluster::before {
background:
linear-gradient(105deg, rgba(203, 184, 139, 0.025), transparent 38%),
radial-gradient(circle at 100% 0%, rgba(187, 156, 97, 0.055), transparent 22rem);
linear-gradient(105deg, color-mix(in srgb, var(--clay) 2.5%, transparent), transparent 38%),
radial-gradient(circle at 100% 0%, color-mix(in srgb, var(--clay) 5.5%, transparent), transparent 22rem);
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-coach-card::after {
background-image: url("/design-elements/learner-dashboard/card-paired-leaves.png");
@ -2600,11 +2593,11 @@
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-root {
--text-strong: #eee8dc;
--text-body: #c3beb2;
--text-muted: #928f86;
--lh-botanical-hair: rgba(184, 160, 110, 0.31);
--lh-botanical-line: rgba(184, 160, 110, 0.22);
--text-strong: var(--ink-0);
--text-body: var(--ink-2);
--text-muted: var(--ink-3);
--lh-botanical-hair: color-mix(in srgb, var(--clay) 31%, transparent);
--lh-botanical-line: color-mix(in srgb, var(--clay) 22%, transparent);
}
[data-theme="dark"] .vg-shell--learner-dashboard .lh-recap__avatar {
border: 0;

View file

@ -95,7 +95,7 @@
position: relative;
z-index: 0;
padding: 0 4px;
background: linear-gradient(90deg, #6b9488, #9fc0b5);
background: linear-gradient(90deg, var(--accent-deep), var(--accent-bright));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
@ -205,6 +205,11 @@
font-weight: 650;
text-align: left;
cursor: pointer;
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-base) var(--ease-out), border-color var(--dur-base) var(--ease-out);
}
.lg-obtn:active {
transform: translateY(1px) scale(0.98);
}
.lg-obtn.primary {
@ -286,9 +291,8 @@
margin-top: var(--sp-3);
padding: 10px 12px;
border-radius: var(--radius);
/* 2026-07-27: 기존 rgba(154,94,20,.28) + #f2b75f 조합은 다크 패널 전제였다.
라이트 패널 위에서는 탄색 배경에 밝은 앰버 글자가 얹혀 1.19:1까지 떨어져
사실상 읽을 없었다. 테마별로 스왑되는 warn 의미 토큰으로 교체한다. */
/* 2026-07-27: 기존 하드코딩 다크 전제 색상 대신
라이트/다크 테마별로 스왑되는 warn 의미 토큰으로 교체한다. */
background: var(--warn-tint);
border: 1px solid color-mix(in srgb, var(--warn-solid) 34%, transparent);
color: var(--warn-text);
@ -335,6 +339,11 @@
color: var(--text-strong);
text-align: left;
cursor: pointer;
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-base) var(--ease-out), border-color var(--dur-base) var(--ease-out);
}
.lg-roleopt:active {
transform: translateY(1px) scale(0.98);
}
.lg-roleopt.is-sel {
@ -353,11 +362,11 @@
}
.lg-roleopt .d.teacher {
background: #5478c4;
background: var(--info-solid);
}
.lg-roleopt .d.admin {
background: #7d818e;
background: var(--neutral-500);
}
.lg-roleopt b {
@ -376,14 +385,19 @@
width: 100%;
min-height: 46px;
margin-top: var(--sp-3);
border: 1px solid rgba(114, 211, 197, 0.58);
border: 1px solid color-mix(in srgb, var(--accent) 58%, transparent);
border-radius: var(--radius);
background: rgba(89, 181, 166, 0.15);
background: color-mix(in srgb, var(--accent-tint) 60%, transparent);
color: var(--accent-deep);
font-family: var(--font-sans);
font-size: 14px;
font-weight: 750;
cursor: pointer;
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-base) var(--ease-out);
}
.lg-devbtn:active {
transform: translateY(1px) scale(0.98);
}
.lg-devbtn:disabled {
@ -394,8 +408,8 @@
.lg-error {
margin: var(--sp-3) 0 0;
color: var(--crit-text);
background: rgba(122, 38, 38, 0.28);
border: 1px solid rgba(255, 145, 145, 0.18);
background: var(--crit-tint);
border: 1px solid color-mix(in srgb, var(--crit-solid) 30%, transparent);
border-radius: var(--radius);
padding: 10px 12px;
font-size: 13px;

View file

@ -172,8 +172,14 @@
grid-template-columns:auto minmax(0,1fr);
align-items:center;
gap:10px;
min-height:28px;
min-height:34px;
padding:4px 6px;
border-radius:var(--radius-sm);
cursor:pointer;
transition:background var(--dur-fast) var(--ease-out);
}
.ob-checks label:hover{
background:color-mix(in srgb,var(--text-strong) 4%,transparent);
}
.ob-checks input{
width:18px;

View file

@ -12,8 +12,8 @@
padding:clamp(24px,5vw,44px);
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:color-mix(in srgb,var(--bg-surface) 92%,white 8%);
box-shadow:0 18px 50px rgba(28,43,40,.10);
background: var(--bg-surface);
box-shadow: var(--shadow-island);
}
.pa-mark{
width:62px;
@ -63,8 +63,13 @@
width:10px;
height:10px;
border-radius:50%;
background:#d89a2b;
box-shadow:0 0 0 5px rgba(216,154,43,.14);
background:var(--warn-solid);
box-shadow:0 0 0 5px var(--warn-tint);
animation:paBreathe 2400ms var(--ease-in-out) infinite;
}
@keyframes paBreathe{
0%,100%{transform:scale(1);opacity:0.85;}
50%{transform:scale(1.2);opacity:1;}
}
.pa-status b{
display:block;

View file

@ -907,8 +907,8 @@
.ps-point-list > div{display:grid;grid-template-columns:minmax(0,1fr) 70px 120px;gap:12px;padding:11px 0;border-bottom:1px solid var(--hair);color:var(--text-body);font-size:var(--fs-xs);}
.ps-point-list b{font-family:var(--font-num);font-variant-numeric:tabular-nums;}
.ps-point-list small{color:var(--text-muted);text-align:right;}
.ps-stepper{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border:1px solid var(--border-subtle);border-radius:var(--radius);overflow:hidden;background:color-mix(in srgb,var(--bg-surface) 78%,transparent);}
.ps-stepper button{min-width:0;display:grid;grid-template-columns:32px minmax(0,1fr);gap:10px;align-items:center;padding:13px 14px;border:0;border-right:1px solid var(--border-subtle);background:transparent;color:var(--text-muted);text-align:left;cursor:pointer;}
.ps-stepper button{min-width:0;display:grid;grid-template-columns:32px minmax(0,1fr);gap:10px;align-items:center;padding:13px 14px;border:0;border-right:1px solid var(--border-subtle);background:transparent;color:var(--text-muted);text-align:left;cursor:pointer;transition:transform var(--dur-fast) var(--ease-spring), background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);}
.ps-stepper button:active{transform:scale(0.96);}
.ps-stepper button:last-child{border-right:0;}
.ps-stepper button > span:first-child{display:grid;place-items:center;width:30px;height:30px;border:1px solid var(--border-subtle);border-radius:50%;font-family:var(--font-num);font-size:11px;font-weight:800;}
.ps-stepper button > span:last-child{min-width:0;display:grid;gap:2px;}

View file

@ -1326,6 +1326,10 @@
.pf-session:first-child{border-top:0;}
.pf-session--action{
cursor:pointer;
transition:transform var(--dur-fast) var(--ease-spring), background var(--dur-fast) var(--ease-out);
}
.pf-session--action:active{
transform:scale(0.985);
}
.pf-session--action:hover{
background:var(--bg-surface-2);

View file

@ -784,7 +784,7 @@
padding: 0 var(--sp-4);
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--text-on-accent, #fff);
color: var(--text-on-accent);
font-size: var(--fs-sm);
font-weight: 720;
text-decoration: none;

View file

@ -184,7 +184,7 @@
.dp-runtime-observation__message.is-waiting {
border-color: color-mix(
in srgb,
var(--botanical-gold, #96743d) 44%,
var(--botanical-gold, var(--clay)) 44%,
var(--border-subtle)
);
}
@ -197,7 +197,7 @@
.dp-runtime-observation__message.is-error {
border-color: color-mix(
in srgb,
var(--danger, #a34a46) 42%,
var(--danger, var(--crit-solid)) 42%,
var(--border-subtle)
);
}
@ -483,7 +483,7 @@
.dp-launch-ticket__cta {
padding: 0 var(--sp-4);
background: var(--accent);
color: var(--text-on-accent, #fff);
color: var(--text-on-accent);
white-space: nowrap;
}
@ -523,7 +523,7 @@
}
.dp-gate--transfer_pending > span {
border-color: var(--botanical-gold, #96743d);
border-color: var(--botanical-gold, var(--clay));
}
.dp-gate--mastered > span {
@ -865,8 +865,8 @@
.dp-form-message--error {
border: 1px solid
color-mix(in srgb, var(--danger, #a34a46) 38%, var(--border-subtle));
color: var(--danger, #8d3834);
color-mix(in srgb, var(--danger, var(--crit-solid)) 38%, var(--border-subtle));
color: var(--danger, var(--crit-text));
}
.dp-form-message--success {
@ -969,7 +969,7 @@
.dp-card--error {
border-color: color-mix(
in srgb,
var(--danger, #a34a46) 35%,
var(--danger, var(--crit-solid)) 35%,
var(--border-subtle)
);
}

View file

@ -300,41 +300,34 @@
/* ── 리뷰 영역 가로 탭 ── */
.sr-tabs {
display: flex;
gap: 6px;
gap: 4px;
padding: 4px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
border-radius: var(--radius-pill);
background: var(--bg-surface-2);
box-shadow: var(--shadow-sm);
}
.sr-tabs button {
position: relative;
flex: 1 1 0;
min-width: 0;
padding: 9px 12px;
padding: 8px 14px;
border: 1px solid transparent;
border-radius: calc(var(--radius) - 2px);
border-radius: var(--radius-pill);
background: transparent;
color: var(--text-body);
font-size: var(--fs-sm);
font-weight: 700;
font-weight: 650;
cursor: pointer;
min-height: 44px;
min-height: 42px;
transition: all var(--dur-fast) var(--ease-spring);
}
/* D6: 비활성 탭이 배경과 붙어 보이지 않으므로 사이마다 얇은 구분선을 세워
"여러 개 중 하나"임을 드러낸다. 가운데 탭이 활성일 때도 구분이 남도록 항상 그리고,
활성 카드와 겹치지 않게 6px 간격 한가운데(-4px) 둔다. */
.sr-tabs button + button::before {
content: "";
position: absolute;
left: -4px;
top: 24%;
bottom: 24%;
width: 1px;
background: var(--border-strong);
opacity: 0.8;
.sr-tabs button:active {
transform: scale(0.96);
}
.sr-tabs button:hover {
color: var(--text-strong);
background: color-mix(in srgb, var(--text-strong) 5%, transparent);
}
.sr-tabs button:focus-visible {
outline: 2px solid var(--focus-ring);
@ -345,6 +338,7 @@
border-color: var(--border-strong);
color: var(--text-strong);
box-shadow: var(--shadow-sm);
font-weight: 750;
}
/* 대기 자리표시자는 사이드 레일에서 컴팩트하게 — 큰 빈 박스 금지 */
@ -485,6 +479,8 @@
font-weight: 650;
letter-spacing: 0;
line-height: 1.45;
text-wrap: balance;
word-break: keep-all;
}
.sr-overview .sr-summary {
font-size: var(--fs-sm);
@ -1517,24 +1513,24 @@
[data-theme="dark"] .sr-card,
[data-theme="dark"] .sr-overview {
background: var(--surface-gradient);
border-color: rgba(203, 227, 220, 0.13);
border-color: color-mix(in srgb, var(--accent) 13%, var(--border-subtle));
box-shadow:
0 16px 42px rgba(3, 9, 8, 0.24),
inset 0 1px 0 rgba(255, 255, 255, 0.035);
var(--shadow-md),
inset 0 1px 0 color-mix(in srgb, var(--bg-surface) 3.5%, transparent);
}
[data-theme="dark"] .sr-card--transcript {
background: linear-gradient(180deg, rgba(13, 24, 22, 0.98), rgba(11, 19, 17, 0.98));
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-app) 98%, transparent), color-mix(in srgb, var(--bg-surface) 98%, transparent));
}
[data-theme="dark"] .sr-overview {
border-color: rgba(125, 162, 148, 0.2);
background: linear-gradient(180deg, rgba(32, 50, 45, 0.94), rgba(18, 31, 28, 0.98));
border-color: color-mix(in srgb, var(--accent) 20%, transparent);
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-surface-2) 94%, transparent), color-mix(in srgb, var(--bg-surface) 98%, transparent));
}
[data-theme="dark"] .sr-card--worksheet {
background: linear-gradient(180deg, rgba(25, 39, 44, 0.94), rgba(16, 27, 31, 0.98));
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-surface-2) 94%, transparent), color-mix(in srgb, var(--bg-surface) 98%, transparent));
}
[data-theme="dark"] .sr-card--prepost,
[data-theme="dark"] .sr-card--rubric {
background: linear-gradient(180deg, rgba(29, 43, 38, 0.92), rgba(18, 30, 27, 0.96));
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-surface-2) 92%, transparent), color-mix(in srgb, var(--bg-surface) 96%, transparent));
}
[data-theme="dark"] .sr-stat,
[data-theme="dark"] .sr-readiness span,
@ -1545,8 +1541,8 @@
[data-theme="dark"] .sr-prepost__field input,
[data-theme="dark"] .sr-feedback,
[data-theme="dark"] .sr-nextline {
background: rgba(255, 255, 255, 0.055);
border-color: rgba(203, 227, 220, 0.12);
background: color-mix(in srgb, var(--text-strong) 5.5%, transparent);
border-color: color-mix(in srgb, var(--accent) 12%, transparent);
}
[data-theme="dark"] .sr-tx__head,
[data-theme="dark"] .sr-turn + .sr-turn,
@ -1554,35 +1550,35 @@
[data-theme="dark"] .sr-ws-limitations,
[data-theme="dark"] .sr-prepost__actions,
[data-theme="dark"] .sr-feedback__src {
border-color: rgba(203, 227, 220, 0.1);
border-color: color-mix(in srgb, var(--accent) 10%, transparent);
}
[data-theme="dark"] .sr-turn__said--client {
color: rgba(234, 240, 241, 0.74);
color: color-mix(in srgb, var(--text-strong) 74%, transparent);
}
[data-theme="dark"] .sr-note--ai {
background: rgba(92, 151, 190, 0.12);
background: color-mix(in srgb, var(--info-solid) 12%, transparent);
}
[data-theme="dark"] .sr-note--warn {
background: rgba(212, 162, 74, 0.12);
background: color-mix(in srgb, var(--warn-solid) 12%, transparent);
}
[data-theme="dark"] .sr-turn--active .sr-turn__said {
background: rgba(125, 162, 148, 0.14);
background: color-mix(in srgb, var(--accent) 14%, transparent);
}
[data-theme="dark"] .sr-practice-return {
border-color: rgba(125, 162, 148, 0.32);
background: rgba(32, 50, 45, 0.88);
border-color: color-mix(in srgb, var(--accent) 32%, transparent);
background: color-mix(in srgb, var(--bg-surface-2) 88%, transparent);
}
[data-theme="dark"] .sr-practice-return__facts > div {
border-color: rgba(203, 227, 220, 0.13);
background: rgba(255, 255, 255, 0.055);
border-color: color-mix(in srgb, var(--accent) 13%, transparent);
background: color-mix(in srgb, var(--text-strong) 5.5%, transparent);
}
[data-theme="dark"] .sr-practice-return.is-error {
border-color: rgba(224, 139, 130, 0.42);
background: rgba(93, 42, 42, 0.3);
border-color: color-mix(in srgb, var(--crit-solid) 42%, transparent);
background: color-mix(in srgb, var(--crit-solid) 30%, transparent);
}
[data-theme="dark"] .sr-feedback--filled {
border-color: rgba(203, 227, 220, 0.12);
background: linear-gradient(135deg, rgba(12, 24, 21, 0.98), rgba(30, 44, 40, 0.92));
border-color: color-mix(in srgb, var(--accent) 12%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, var(--bg-app) 98%, transparent), color-mix(in srgb, var(--bg-surface) 92%, transparent));
}
@media (max-width: 1180px) {

View file

@ -9,9 +9,9 @@
===================================================================== */
.sx-alliance-checkpoint {
--checkpoint-ink: var(--text-strong, #132822);
--checkpoint-muted: var(--text-muted, #66736f);
--checkpoint-line: color-mix(in srgb, var(--accent, #2f695b) 18%, transparent);
--checkpoint-ink: var(--text-strong);
--checkpoint-muted: var(--text-muted);
--checkpoint-line: color-mix(in srgb, var(--accent) 18%, transparent);
--checkpoint-hair: color-mix(in srgb, var(--checkpoint-ink) 12%, transparent);
--checkpoint-title: clamp(1.02rem, 1.5vw, 1.24rem);
--checkpoint-pad: clamp(16px, 1.6vw, 24px);
@ -23,7 +23,7 @@
margin: 14px auto 0;
padding: 14px 16px;
color: var(--checkpoint-ink);
background: color-mix(in srgb, var(--surface, #fff) 94%, var(--accent, #2f695b) 6%);
background: color-mix(in srgb, var(--surface) 94%, var(--accent) 6%);
border: 1px solid var(--checkpoint-line);
border-radius: 14px;
word-break: keep-all;
@ -50,9 +50,9 @@
width: 9px;
height: 9px;
flex: 0 0 auto;
background: var(--accent, #2f695b);
background: var(--accent);
border-radius: 999px;
box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent, #2f695b) 12%, transparent);
box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 12%, transparent);
}
.sx-alliance-checkpoint.is-loading .sx-alliance-checkpoint__signal {
@ -60,8 +60,8 @@
}
.sx-alliance-checkpoint.is-error {
border-color: color-mix(in srgb, var(--crit-solid, #a13c3c) 34%, transparent);
background: color-mix(in srgb, var(--surface, #fff) 94%, var(--crit-tint, #faeae8) 6%);
border-color: color-mix(in srgb, var(--crit-solid) 34%, transparent);
background: color-mix(in srgb, var(--surface) 94%, var(--crit-tint) 6%);
}
/* ── 확장 카드 공통 ───────────────────────────────────────────────── */
@ -69,12 +69,12 @@
.sx-alliance-checkpoint.is-expanded {
display: block;
padding: var(--checkpoint-pad);
border-color: color-mix(in srgb, var(--accent, #2f695b) 28%, transparent);
box-shadow: 0 18px 50px rgb(18 42 35 / 9%);
border-color: color-mix(in srgb, var(--accent) 28%, transparent);
box-shadow: var(--shadow-island);
}
.sx-alliance-checkpoint.is-pre {
border-top: 3px solid var(--accent, #2f695b);
border-top: 3px solid var(--accent);
}
.sx-alliance-checkpoint__head {
@ -91,7 +91,7 @@
}
.sx-alliance-checkpoint__eyebrow {
color: var(--accent, #2f695b);
color: var(--accent);
font-size: 0.68rem;
font-weight: 760;
letter-spacing: 0.12em;
@ -136,12 +136,12 @@
}
.sx-alliance-checkpoint__progress.is-done {
color: var(--accent, #2f695b);
background: color-mix(in srgb, var(--accent, #2f695b) 13%, transparent);
color: var(--accent);
background: color-mix(in srgb, var(--accent) 13%, transparent);
}
.sx-alliance-checkpoint__progress.is-done b {
color: var(--accent, #2f695b);
color: var(--accent);
}
/* ── 3축 ─────────────────────────────────────────────────────────── */
@ -216,25 +216,25 @@
gap: 2px;
padding: 7px 4px;
cursor: pointer;
background: var(--bg-surface-2, #f4f2ee);
background: var(--bg-surface-2);
border: 1px solid var(--checkpoint-hair);
border-radius: 10px;
transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
}
.sx-alliance-checkpoint__scale label:hover {
background: color-mix(in srgb, var(--accent, #2f695b) 8%, var(--bg-surface-2, #f4f2ee));
border-color: color-mix(in srgb, var(--accent, #2f695b) 42%, transparent);
background: color-mix(in srgb, var(--accent) 8%, var(--bg-surface-2));
border-color: color-mix(in srgb, var(--accent) 42%, transparent);
}
.sx-alliance-checkpoint__scale label.is-selected {
color: var(--text-on-accent, #fbfaf8);
background: var(--accent, #2f695b);
border-color: var(--accent, #2f695b);
color: var(--text-on-accent);
background: var(--accent);
border-color: var(--accent);
}
.sx-alliance-checkpoint__scale label.is-selected:hover {
background: var(--accent, #2f695b);
background: var(--accent);
}
.sx-alliance-checkpoint__scale input {
@ -245,7 +245,7 @@
}
.sx-alliance-checkpoint__scale label:focus-within {
outline: 3px solid color-mix(in srgb, var(--accent, #2f695b) 34%, transparent);
outline: 3px solid color-mix(in srgb, var(--accent) 34%, transparent);
outline-offset: 2px;
}
@ -306,7 +306,7 @@ fieldset[disabled] .sx-alliance-checkpoint__scale label {
.sx-alliance-checkpoint__error {
margin: 14px 0 0;
color: var(--crit-text, #a13c3c);
color: var(--crit-text);
font-size: 0.78rem;
}
@ -316,7 +316,7 @@ fieldset[disabled] .sx-alliance-checkpoint__scale label {
gap: 7px;
width: min(1180px, calc(100% - 32px));
margin: 10px auto 0;
color: var(--text-muted, #66736f);
color: var(--text-muted);
font-size: 0.72rem;
}

File diff suppressed because it is too large Load diff

View file

@ -47,9 +47,13 @@
padding: 10px 12px;
border-radius: var(--radius);
cursor: pointer;
transition: background var(--dur-fast) var(--ease-out),
transition: transform var(--dur-fast) var(--ease-spring),
background var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out);
}
.vg-set__nav-item:active {
transform: scale(0.96);
}
.vg-set__nav-item:hover {
background: var(--bg-surface-2);
color: var(--text-strong);

View file

@ -87,6 +87,11 @@
background: transparent;
font: 650 var(--fs-sm) / 1 var(--font-sans);
cursor: pointer;
transition: transform var(--dur-fast) var(--ease-spring), color var(--dur-fast) var(--ease-out), background var(--dur-fast) var(--ease-out);
}
.g6-lenses button:active {
transform: scale(0.96);
}
.g6-lenses button:hover {

View file

@ -15,6 +15,9 @@ html,
body,
#root {
height: 100%;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
}
body {
@ -23,9 +26,10 @@ body {
color: var(--text-strong);
font-size: var(--fs-body);
line-height: 1.6;
letter-spacing: 0;
letter-spacing: -0.01em;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overscroll-behavior-y: contain;
/* 한국어 조판: 기본값(normal) 한글을 음절 단위로 끊어 "오전" "오/전",
"검토 대기" "검/토 대기" 쪼개진다. 어절 단위로 끊고, 끊을 없는
토큰(URL·ID) 강제 줄바꿈한다. 세션 화면이 쓰던 규칙을 전역으로 올렸다. */
@ -37,9 +41,11 @@ body {
a {
color: var(--accent-deep);
text-decoration: none;
transition: color var(--dur-fast) var(--ease-out);
}
a:hover {
text-decoration: underline;
color: var(--accent);
}
button {
@ -61,13 +67,13 @@ svg {
display: block;
}
/* 포커스 — 키보드 사용자만 ring (마우스 클릭엔 없음) */
/* 포커스 — 키보드 사용자만 고대비 ring (마우스 클릭엔 없음) */
:focus {
outline: none;
}
:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--focus-ring);
outline: 3px solid var(--focus-ring);
outline-offset: 2px;
border-radius: var(--radius-sm);
}

View file

@ -149,8 +149,14 @@
--radius-lg: 12px; /* 패널 */
--gutter: 24px;
--bottom-bar-h: 60px;
--radius-xl: 16px;
--radius-pill: 9999px;
--shadow-sm: 0 1px 2px rgba(28, 42, 42, 0.05);
--shadow-md: 0 4px 14px rgba(28, 42, 42, 0.07); /* 모달·팝오버만 */
--shadow-island: 0 8px 24px rgba(28, 42, 42, 0.08), 0 2px 6px rgba(28, 42, 42, 0.04);
--shadow-float: 0 14px 36px rgba(28, 42, 42, 0.12), 0 3px 10px rgba(28, 42, 42, 0.06);
/* ── 래스터 디자인 요소 ── */
--asset-warm-elements: url("/design-elements/clinical-paper-ambient.png");
@ -162,6 +168,7 @@
/* ── 모션 §3.9 ── */
--ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
--ease-in-out: cubic-bezier(0.45, 0.05, 0.55, 0.95);
--ease-spring: cubic-bezier(0.32, 0.72, 0, 1);
--dur-fast: 120ms; /* hover·포커스 */
--dur-base: 200ms; /* 버튼·토글·드롭다운 */
--dur-slow: 320ms; /* 패널 전환·모달 */

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB