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

@ -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;