feat(coach): AI 코칭 버튼 및 버블 팝업 대화형 조언 기능 구현
This commit is contained in:
parent
8a34dd2afc
commit
70d7cb34a3
36 changed files with 4521 additions and 1264 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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={[
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)}`}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; /* 패널 전환·모달 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue