Stabilize runtime auth and E2E coverage

This commit is contained in:
Yun Chan 2026-06-26 14:47:00 +09:00
parent 6a3e3b541c
commit 188e899394
133 changed files with 55987 additions and 6775 deletions

View file

@ -6,22 +6,22 @@
( 2),
(·· , ), "감지되되 단언되지 않게".
(§4.7): SVG + CSS/Web Animations + Web Audio (1).
(§4.7): Live2D Cubism 4 + SVG/CSS/Web Audio .
- rAF useAvatarMotion: 호흡 + + (RMS) + saccade
- 6 (persona.ts): eyelidDrop/gazeAvert/shoulderTurn/
breathRate/auraHue/blinkInterval. 8°0° ( ).
- transform/opacity (layout ). 2 .
- prefers-reduced-motion: 호흡/ + .
- analyser=null: / .
- analyser=null: .
Rive 2
.riv ,
Live2D
.model3.json ,
(persona / state / affect / analyser / rapport)
<svg>+useAvatarMotion <RiveAvatar> .
AffectParams(persona.ts) .riv state-machine input wiring.
Live2DAvatar Cubism model parameter wiring . /
SVG .
===================================================================== */
import { useEffect, useMemo, useState } from "react";
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react";
import {
ageLookFor,
baseResistanceOf,
@ -41,6 +41,10 @@ import { Mouth } from "./Mouth";
Session.tsx ClientAvatar . */
export type { AvatarState, AvatarAffect, AvatarPersona } from "./persona";
const Live2DAvatar = lazy(() =>
import("./Live2DAvatar").then((mod) => ({ default: mod.Live2DAvatar })),
);
export interface ClientAvatarProps {
persona: AvatarPersona;
state: AvatarState;
@ -53,7 +57,7 @@ export interface ClientAvatarProps {
*/
rapport?: number;
/**
* analyser 0~1.
* analyser 0~1.
* null/ speaking .
*/
speakingProgress?: number | null;
@ -89,6 +93,29 @@ const STATE_TEXT: Record<AvatarState, string> = {
speaking: "이야기하는 중",
};
const DISABLED_LIVE2D_MODEL_URLS = new Set(["off", "false", "none"]);
const BUNDLED_DEMO_LIVE2D_MODEL_PATHS = new Set([
"/live2d/mao/Mao.model3.json",
"/live2d/haru/haru_greeter_t03.model3.json",
]);
function isBundledDemoLive2DModel(url: string): boolean {
try {
return BUNDLED_DEMO_LIVE2D_MODEL_PATHS.has(
new URL(url, "http://vignette.local").pathname,
);
} catch {
return false;
}
}
function resolveLive2DModelUrl(persona: AvatarPersona): string | null {
const url = persona.live2dModelUrl?.trim();
if (!url || DISABLED_LIVE2D_MODEL_URLS.has(url.toLowerCase())) return null;
if (import.meta.env.PROD && isBundledDemoLive2DModel(url)) return null;
return url;
}
export function ClientAvatar({
persona,
state,
@ -115,6 +142,29 @@ export function ClientAvatar({
() => resolveAffectParams(affect, state, effectiveRapport),
[affect, state, effectiveRapport],
);
const live2DModelUrl = useMemo(() => resolveLive2DModelUrl(persona), [persona]);
const [live2DReady, setLive2DReady] = useState(false);
const [live2DFailed, setLive2DFailed] = useState(false);
const [live2DError, setLive2DError] = useState<string | null>(null);
useEffect(() => {
setLive2DReady(false);
setLive2DFailed(false);
setLive2DError(null);
}, [live2DModelUrl]);
const handleLive2DReady = useCallback(() => {
setLive2DReady(true);
setLive2DError(null);
}, []);
const handleLive2DUnavailable = useCallback((reason: string) => {
setLive2DReady(false);
setLive2DFailed(true);
setLive2DError(reason);
}, []);
const shouldUseLive2D = Boolean(live2DModelUrl) && !live2DFailed;
// 모션 루프 (reduced 면 정지)
const frame = useAvatarMotion({
@ -148,6 +198,8 @@ export function ClientAvatar({
style={{ width: size }}
data-state={state}
data-affect={affect}
data-live2d={live2DReady ? "ready" : shouldUseLive2D ? "loading" : "off"}
data-live2d-error={live2DError ?? undefined}
data-realism={realism} /* 사실성은 데이터로만 유지(시각 표식 비노출, §4.6) */
aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}`}
>
@ -171,7 +223,7 @@ export function ClientAvatar({
/>
<svg
className="vg-avatar__svg"
className={"vg-avatar__svg" + (live2DReady ? " is-live2d-covered" : "")}
viewBox="0 0 200 200"
width={size}
height={size}
@ -205,6 +257,23 @@ export function ClientAvatar({
</g>
</g>
</svg>
{shouldUseLive2D && live2DModelUrl ? (
<Suspense fallback={null}>
<Live2DAvatar
modelUrl={live2DModelUrl}
state={state}
affect={affect}
params={params}
analyser={analyser}
speakingProgress={speakingProgress}
reduced={reduced}
size={size}
onReady={handleLive2DReady}
onUnavailable={handleLive2DUnavailable}
/>
</Suspense>
) : null}
</div>
{/* 페르소나 메타 + 상태 텍스트 */}
@ -237,7 +306,10 @@ const AVATAR_CSS = `
}
.vg-avatar__aura.is-reduced{animation:none;}
@keyframes vgAuraBreathe{0%,100%{opacity:.85;transform:scale(1)}50%{opacity:1;transform:scale(1.04)}}
.vg-avatar__svg{position:relative;z-index:1;display:block;}
.vg-avatar__svg{position:relative;z-index:1;display:block;transition:opacity var(--dur-base) var(--ease-out);}
.vg-avatar__svg.is-live2d-covered{opacity:0;}
.vg-avatar__live2d{position:absolute;inset:0;z-index:2;display:flex;align-items:center;justify-content:center;pointer-events:none;}
.vg-avatar__live2d-canvas{width:100%;height:100%;display:block;}
.vg-avatar__meta{display:flex;flex-direction:column;align-items:center;gap:3px;text-align:center;}
.vg-avatar__persona{font-size:var(--fs-sm);font-weight:600;color:var(--text-strong);}
.vg-avatar__state{font-size:var(--fs-xs);color:var(--text-muted);}

View file

@ -0,0 +1,383 @@
import { useEffect, useRef } from "react";
import type { AffectParams, AvatarAffect, AvatarState } from "./persona";
type PixiNamespace = typeof import("pixi.js");
type PixiApplication = import("pixi.js").Application;
type Live2DModule = typeof import("pixi-live2d-display/cubism4");
type Live2DModelCtor = Live2DModule["Live2DModel"];
type Live2DModelInstance = import("pixi-live2d-display/cubism4").Live2DModel;
interface Live2DAvatarProps {
modelUrl: string;
state: AvatarState;
affect: AvatarAffect;
params: AffectParams;
analyser: AnalyserNode | null;
speakingProgress: number | null;
reduced: boolean;
size: number;
onReady: () => void;
onUnavailable: (reason: string) => void;
}
type MutableLive2DModel = Live2DModelInstance & {
internalModel?: {
coreModel?: {
setParameterValueById?: (id: string, value: number, weight?: number) => void;
addParameterValueById?: (id: string, value: number, weight?: number) => void;
};
};
};
type Live2DRefState = Pick<
Live2DAvatarProps,
"state" | "affect" | "params" | "analyser" | "speakingProgress" | "reduced" | "size"
>;
declare global {
interface Window {
PIXI?: PixiNamespace;
Live2DCubismCore?: unknown;
}
}
const DEFAULT_CUBISM_CORE = "/live2d/live2dcubismcore.min.js";
const CORE_SCRIPT =
import.meta.env.VITE_LIVE2D_CUBISM_CORE?.trim() || DEFAULT_CUBISM_CORE;
let pixiRegistered = false;
let cubismCorePromise: Promise<void> | null = null;
let runtimePromise:
| Promise<{ PIXI: PixiNamespace; Live2DModel: Live2DModelCtor; MotionPreloadStrategy: Live2DModule["MotionPreloadStrategy"] }>
| null = null;
function registerPixi(PIXI: PixiNamespace, Live2DModel: Live2DModelCtor) {
if (pixiRegistered) return;
window.PIXI = PIXI;
Live2DModel.registerTicker(PIXI.Ticker);
pixiRegistered = true;
}
function loadLive2DRuntime() {
if (!runtimePromise) {
runtimePromise = Promise.all([
import("pixi.js"),
import("pixi-live2d-display/cubism4"),
]).then(([PIXI, live2d]) => {
registerPixi(PIXI, live2d.Live2DModel);
return {
PIXI,
Live2DModel: live2d.Live2DModel,
MotionPreloadStrategy: live2d.MotionPreloadStrategy,
};
});
}
return runtimePromise;
}
function hasCubismCore(): boolean {
return typeof window !== "undefined" && Boolean(window.Live2DCubismCore);
}
function appendScript(src: string): Promise<void> {
if (hasCubismCore()) return Promise.resolve();
const existing = Array.from(document.scripts).find(
(script) => script.dataset.vgLive2dCore === src,
);
if (existing) {
return new Promise((resolve, reject) => {
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error(`Cubism Core load failed: ${src}`)), {
once: true,
});
});
}
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.async = true;
script.dataset.vgLive2dCore = src;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Cubism Core load failed: ${src}`));
document.head.appendChild(script);
});
}
function ensureCubismCore(): Promise<void> {
if (hasCubismCore()) return Promise.resolve();
if (!cubismCorePromise) {
cubismCorePromise = (async () => {
await appendScript(CORE_SCRIPT);
if (!hasCubismCore()) throw new Error("Cubism Core did not initialize.");
})().catch((err) => {
cubismCorePromise = null;
throw err;
});
}
return cubismCorePromise;
}
function fitModel(model: Live2DModelInstance, size: number) {
model.anchor.set(0.5, 0.5);
model.scale.set(1);
const bounds = model.getLocalBounds();
const boundsWidth = Math.max(1, bounds.width);
const boundsHeight = Math.max(1, bounds.height);
const scale = Math.min((size * 0.86) / boundsWidth, (size * 0.98) / boundsHeight);
model.scale.set(scale);
model.x = size / 2;
model.y = size * 0.58;
}
function fallbackMouthTarget(tSec: number, progress: number | null): number {
if (progress !== null && progress >= 1) return 0;
const syl = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 5.5);
const jitter = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 11 + 1.3);
return Math.min(0.85, 0.18 + 0.42 * syl * jitter);
}
function mouthFromAnalyser(
analyser: AnalyserNode | null,
buffer: Float32Array<ArrayBuffer>,
previous: number,
dtSec: number,
tSec: number,
progress: number | null,
): number {
let target = fallbackMouthTarget(tSec, progress);
if (analyser) {
analyser.getFloatTimeDomainData(buffer);
let sum = 0;
for (let i = 0; i < buffer.length; i++) sum += buffer[i] * buffer[i];
const rms = Math.sqrt(sum / buffer.length);
target = rms < 0.04 ? 0 : Math.min(1, rms * 3.2);
}
const tau = 0.16;
const alpha = 1 - Math.exp(-dtSec / tau);
return previous + (target - previous) * alpha;
}
function setParam(model: MutableLive2DModel, id: string, value: number, weight = 0.85) {
try {
model.internalModel?.coreModel?.setParameterValueById?.(id, value, weight);
} catch {
/* Some models omit optional standard parameters. */
}
}
function applyLive2DParams(
model: MutableLive2DModel,
snapshot: Live2DRefState,
mouth: number,
tSec: number,
) {
const { state, affect, params, reduced } = snapshot;
if (reduced) return;
const gazeBase = state === "listening" ? -params.gazeAvert * 0.15 : -params.gazeAvert * 0.22;
const thinkingDrift = state === "thinking" ? Math.sin(tSec * 0.9) * 0.08 : 0;
const speakEnergy = state === "speaking" ? mouth : 0;
const smile = Math.max(0, params.mouthCurve);
const downturn = Math.max(0, -params.mouthCurve);
setParam(model, "ParamMouthOpenY", speakEnergy, 1);
setParam(model, "ParamA", speakEnergy, 1);
setParam(model, "ParamMouthForm", params.mouthCurve * 0.25, 0.35);
setParam(model, "ParamMouthUp", smile * 0.45, 0.25);
setParam(model, "ParamMouthDown", downturn * 0.45, 0.25);
setParam(model, "ParamMouthAngry", affect === "resistant" ? 0.55 : 0, 0.25);
setParam(model, "ParamEyeBallX", gazeBase + thinkingDrift, 0.5);
setParam(model, "ParamEyeBallY", state === "thinking" ? -0.12 : 0.03, 0.45);
setParam(model, "ParamAngleX", gazeBase * 14, 0.35);
setParam(model, "ParamAngleY", state === "thinking" ? -3 : 1, 0.3);
setParam(model, "ParamAngleZ", -params.shoulderTurn * 0.25, 0.25);
setParam(model, "ParamBodyAngleX", -params.shoulderTurn * 0.45, 0.35);
setParam(model, "ParamBreath", 0.5 + Math.sin(tSec * 2 * Math.PI / params.breathPeriod) * 0.22, 0.35);
}
function focusPointFor(state: AvatarState, params: AffectParams, size: number): [number, number] {
if (state === "thinking") return [size * 0.42, size * 0.62];
if (state === "listening") return [size * 0.56, size * 0.48];
if (state === "speaking") return [size * 0.5, size * 0.46];
return [size * (0.5 - params.gazeAvert * 0.008), size * 0.52];
}
function expressionsFor(affect: AvatarAffect): string[] {
if (affect === "depressed") return ["sad", "f02", "f03", "exp_05", "exp_06"];
if (affect === "anxious") return ["surprised", "f03", "f04", "exp_03", "exp_04"];
if (affect === "resistant") return ["angry", "f06", "f07", "exp_07", "exp_08"];
return ["normal", "f00", "f01", "exp_01", "exp_02"];
}
async function tryMotion(model: Live2DModelInstance, groups: string[]) {
for (const group of groups) {
const ok = await model.motion(group).catch(() => false);
if (ok) return;
}
}
async function tryExpression(model: Live2DModelInstance, names: string[]) {
for (const name of names) {
const ok = await model.expression(name).catch(() => false);
if (ok) return;
}
}
export function Live2DAvatar({
modelUrl,
state,
affect,
params,
analyser,
speakingProgress,
reduced,
size,
onReady,
onUnavailable,
}: Live2DAvatarProps) {
const hostRef = useRef<HTMLDivElement>(null);
const appRef = useRef<PixiApplication | null>(null);
const modelRef = useRef<MutableLive2DModel | null>(null);
const snapshotRef = useRef<Live2DRefState>({
state,
affect,
params,
analyser,
speakingProgress,
reduced,
size,
});
useEffect(() => {
snapshotRef.current = { state, affect, params, analyser, speakingProgress, reduced, size };
}, [state, affect, params, analyser, speakingProgress, reduced, size]);
useEffect(() => {
const model = modelRef.current;
if (!model) return;
model.autoUpdate = !reduced;
}, [reduced]);
useEffect(() => {
const model = modelRef.current;
if (!model || reduced) return;
const [x, y] = focusPointFor(state, params, size);
model.focus(x, y);
void tryExpression(model, expressionsFor(affect));
if (state === "speaking") void tryMotion(model, ["Speak", "Speaking", "TapBody"]);
else if (state === "thinking") void tryMotion(model, ["Think", "Thinking"]);
else if (state === "idle") void tryMotion(model, ["Idle", "idle"]);
}, [affect, params, reduced, size, state]);
useEffect(() => {
const app = appRef.current;
const model = modelRef.current;
if (!app || !model) return;
app.renderer.resize(size, size);
fitModel(model, size);
}, [size]);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
let cancelled = false;
let mouth = 0;
let lastT = performance.now();
const t0 = lastT;
const audioBuffer: Float32Array<ArrayBuffer> = new Float32Array(1024);
const tick = () => {
const model = modelRef.current;
if (!model) return;
const now = performance.now();
const dtSec = Math.min(0.05, (now - lastT) / 1000);
const tSec = (now - t0) / 1000;
lastT = now;
const snapshot = snapshotRef.current;
if (snapshot.state === "speaking") {
mouth = mouthFromAnalyser(
snapshot.analyser,
audioBuffer,
mouth,
dtSec,
tSec,
snapshot.speakingProgress,
);
} else if (mouth > 0.001) {
mouth = mouth * Math.exp(-dtSec / 0.1);
if (mouth < 0.005) mouth = 0;
}
applyLive2DParams(model, snapshot, mouth, tSec);
};
(async () => {
try {
await ensureCubismCore();
const runtime = await loadLive2DRuntime();
if (cancelled) return;
const app = new runtime.PIXI.Application({
width: size,
height: size,
antialias: true,
autoDensity: true,
backgroundAlpha: 0,
resolution: Math.min(window.devicePixelRatio || 1, 2),
});
appRef.current = app;
const canvas = app.view as HTMLCanvasElement;
canvas.className = "vg-avatar__live2d-canvas";
canvas.setAttribute("aria-hidden", "true");
host.appendChild(canvas);
const model = (await runtime.Live2DModel.from(modelUrl, {
autoInteract: false,
autoUpdate: !snapshotRef.current.reduced,
motionPreload: runtime.MotionPreloadStrategy.IDLE,
})) as MutableLive2DModel;
if (cancelled) {
model.destroy({ children: true, texture: true, baseTexture: true });
app.destroy(true, { children: true, texture: true, baseTexture: true });
return;
}
modelRef.current = model;
fitModel(model, size);
app.stage.addChild(model);
app.ticker.add(tick);
void tryMotion(model, ["Idle", "idle"]);
onReady();
} catch (err) {
if (!cancelled) {
const reason = err instanceof Error ? err.message : "Live2D model load failed.";
onUnavailable(reason);
}
}
})();
return () => {
cancelled = true;
const app = appRef.current;
const model = modelRef.current;
if (app) app.ticker.remove(tick);
modelRef.current = null;
appRef.current = null;
if (model && !model.destroyed) {
model.destroy({ children: true, texture: true, baseTexture: true });
}
if (app) {
app.destroy(true, { children: true, texture: true, baseTexture: true });
}
host.replaceChildren();
};
}, [modelUrl, onReady, onUnavailable, size]);
return <div className="vg-avatar__live2d" ref={hostRef} aria-hidden="true" />;
}

View file

@ -34,6 +34,8 @@ export interface AvatarPersona {
* affect="resistant" 0.6, 0 .
*/
resistance?: number;
/** Optional persona-specific Live2D Cubism 3/4 model3.json URL. Omit for SVG fallback. */
live2dModelUrl?: string | null;
}
/* 6 (§4.5)

View file

@ -59,10 +59,10 @@ function smoothMouthFromRMS(
return prev + (target - prev) * alpha;
}
/* analyser (§ : / )
speakingProgress(0~1, ) ,
RMS ( ). */
function fakeMouthTarget(tSec: number, progress: number | null): number {
/* analyser
speakingProgress(0~1, )
. . */
function fallbackMouthTarget(tSec: number, progress: number | null): number {
if (progress !== null) {
// 타이핑 진행 중에만 입을 움직임. 진행이 멈추면(완료) 닫힘.
if (progress >= 1) return 0;
@ -72,17 +72,14 @@ function fakeMouthTarget(tSec: number, progress: number | null): number {
const base = 0.18 + 0.42 * syl * jitter;
return Math.min(0.85, base);
}
// progress 미제공: 차분한 의사 발화 (말하는 듯한 진폭)
const a = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 4.5);
const b = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 9.2 + 0.7);
return Math.min(0.8, 0.15 + 0.45 * a * b);
return 0;
}
export interface AvatarMotionOptions {
state: AvatarState;
params: AffectParams;
analyser: AnalyserNode | null;
/** analyser 없을 때 가짜 립싱크용 타이핑 진행도 0~1 (null=의사 발화) */
/** analyser 없을 때 선택적으로 쓰는 타이핑 진행도 0~1 */
speakingProgress?: number | null;
/** false 면 루프 정지(reduced-motion) — IDLE_FRAME 고정 */
enabled: boolean;
@ -188,14 +185,14 @@ export function useAvatarMotion({
}
}
// ── 4) 립싱크 (speaking 시만; analyser 우선, 없으면 폴백) ──
// ── 4) 립싱크 (speaking 시만; analyser 우선, 없으면 정적 폴백) ──
let mouth = mouthPrev;
if (st === "speaking") {
const a = analyserRef.current;
if (a) {
mouthPrev = smoothMouthFromRMS(a, audioBuf, mouthPrev, dtSec);
} else {
const target = fakeMouthTarget(tSec, progressRef.current ?? null);
const target = fallbackMouthTarget(tSec, progressRef.current ?? null);
const tau = 0.12;
const alpha = 1 - Math.exp(-dtSec / tau);
mouthPrev = mouthPrev + (target - mouthPrev) * alpha;

View file

@ -11,6 +11,8 @@ export interface AppShellProps {
hideNav?: boolean;
/** 메인 패딩·최대폭 제거 (풀-블리드 레이아웃) */
bleed?: boolean;
/** 톱바까지 제거하는 실제 전체화면 작업 공간 */
hideTopbar?: boolean;
}
/**
@ -18,13 +20,13 @@ export interface AppShellProps {
* body[data-role] AuthProvider ( ).
* ( ) (RequireAuth) .
*/
export function AppShell({ children, contextLabel, hideNav, bleed }: AppShellProps) {
export function AppShell({ children, contextLabel, hideNav, bleed, hideTopbar }: AppShellProps) {
const { user } = useAuth();
const showNav = !hideNav && !!user;
return (
<div className="vg-shell">
<Topbar contextLabel={contextLabel} />
<div className={"vg-shell" + (hideTopbar ? " vg-shell--fullscreen" : "")}>
{hideTopbar ? null : <Topbar contextLabel={contextLabel} />}
<div className={"vg-shell__body" + (showNav ? "" : " vg-shell__body--bare")}>
{showNav ? <Sidebar role={user.role} /> : null}
<main className={"vg-main" + (bleed ? " vg-main--bleed" : "")}>

View file

@ -8,6 +8,9 @@
min-height: 100vh;
background: var(--bg-app);
}
.vg-shell--fullscreen {
min-height: 100dvh;
}
/* ── 톱바 ── */
.vg-topbar {
@ -42,7 +45,7 @@
.vg-topbar__wm {
font-size: 17px;
font-weight: 700;
letter-spacing: -0.02em;
letter-spacing: 0;
color: var(--text-strong);
}
.vg-topbar__wm .v {
@ -219,6 +222,9 @@
.vg-shell__body {
grid-template-columns: var(--nav-w-collapsed) 1fr;
}
.vg-shell__body--bare {
grid-template-columns: 1fr;
}
.vg-nav {
padding: var(--sp-4) var(--sp-2);
}
@ -233,6 +239,49 @@
}
}
@media (max-width: 720px) {
html {
scroll-padding-top: calc(var(--topbar-h) + 68px);
scroll-padding-bottom: var(--sp-6);
}
.vg-shell__body {
display: block;
}
.vg-shell__body--bare {
display: block;
}
.vg-nav {
position: sticky;
top: var(--topbar-h);
z-index: 29;
height: 58px;
padding: 7px max(12px, env(safe-area-inset-left)) 7px max(12px, env(safe-area-inset-right));
border-right: 0;
border-top: 0;
border-bottom: 1px solid var(--hair);
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
}
.vg-nav__label,
.vg-nav__spacer,
.vg-nav__foot {
display: none;
}
.vg-nav__item {
min-width: 112px;
height: 44px;
justify-content: center;
padding: 0 14px;
gap: 8px;
white-space: nowrap;
}
.vg-nav__item span:not(.vg-nav__ic) {
display: inline;
font-size: 13px;
}
.vg-topbar__role {
display: none;
}
@ -242,4 +291,7 @@
.vg-main {
padding: var(--sp-5) var(--sp-4) var(--sp-7);
}
.vg-main--bleed {
padding: 0;
}
}

View file

@ -1,4 +1,4 @@
import { useId } from "react";
import { forwardRef, useId } from "react";
import type { InputHTMLAttributes, ReactNode } from "react";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
@ -6,16 +6,20 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
}
/** Input — radius 6px, 포커스 보더색 + ring. 좌측바 금지. §7.2 */
export function Input({ invalid, className, ...rest }: InputProps) {
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ invalid, className, ...rest },
ref,
) {
const cls = ["vg-input", className ?? ""].filter(Boolean).join(" ");
return (
<input
ref={ref}
className={cls}
aria-invalid={invalid ? "true" : undefined}
{...rest}
/>
);
}
});
export interface FieldProps {
/** 라벨 텍스트 */

View file

@ -1,6 +1,6 @@
import { clamp01 } from "../../lib/format";
export type ProgressTone = "accent" | "muted" | "warn" | "clay";
export type ProgressTone = "accent" | "muted" | "warn" | "crit" | "clay";
export interface ProgressBarProps {
/** 0~1 비율 (또는 value/max) */

View file

@ -34,7 +34,7 @@
.vg-sechead__title {
font-size: var(--fs-h2);
font-weight: 600;
letter-spacing: -0.015em;
letter-spacing: 0;
color: var(--text-strong);
line-height: 1.35;
}
@ -228,7 +228,7 @@
font-weight: 700;
color: var(--text-strong);
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
letter-spacing: 0;
}
.vg-statline__lab {
font-size: var(--fs-xs);
@ -263,6 +263,9 @@
.vg-progress__fill--warn {
background: var(--warn-solid);
}
.vg-progress__fill--crit {
background: var(--crit-solid);
}
.vg-progress__fill--clay {
background: var(--clay);
}
@ -321,7 +324,7 @@
.vg-empty__title {
font-size: var(--fs-h1);
font-weight: 700;
letter-spacing: -0.02em;
letter-spacing: 0;
color: var(--text-strong);
line-height: 1.3;
}

View file

@ -3,12 +3,22 @@
계약: apps/api/app/routes (auth.py, sessions.py).
- credentials:"include" (BFF __Host-vignette_sid HttpOnly ).
- ApiError .
- SSE: GET /sessions/{id}/stream token/done/ping/error .
- SSE: POST /sessions/{id}/stream token/done/ping/error .
===================================================================== */
// Vite 환경변수. 기본 "/api" (vite proxy 또는 nginx 가 백엔드로 라우팅).
const API_BASE: string =
(import.meta.env.VITE_API_BASE as string | undefined) ?? "/api";
function defaultApiBase(): string {
if (typeof window !== "undefined") {
const host = window.location.hostname;
if (host === "vignette.chanpaca.net" || host.endsWith(".pages.dev")) {
return "https://api-vignette.chanpaca.net";
}
}
return "/api";
}
const configuredApiBase = (import.meta.env.VITE_API_BASE as string | undefined)?.trim();
const API_BASE: string = configuredApiBase || defaultApiBase();
export class ApiError extends Error {
readonly status: number;
@ -43,6 +53,16 @@ function joinUrl(path: string): string {
return `${base}${p}`;
}
export function apiUrl(path: string): string {
return joinUrl(path);
}
export function apiWsUrl(path: string): string {
const url = new URL(joinUrl(path), window.location.origin);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return url.toString();
}
async function parseError(res: Response): Promise<ApiError> {
let detail = res.statusText || "request failed";
let body: unknown = undefined;
@ -126,19 +146,47 @@ export const api = {
/** GET /auth/me — auth.py MeResponse */
export interface MeResponse {
user_id: string;
email: string;
display_name: string;
role: string; // "learner" | "teacher" | "admin"
cohort_ids: string[];
}
export interface AuthConfigResponse {
google_oauth_configured: boolean;
allowed_email_domains: string[];
redirect_uri: string;
dev_login_enabled: boolean;
}
export const authApi = {
config: () => api.get<AuthConfigResponse>("/auth/config"),
};
export type SessionStage = "라포" | "탐색" | "개입" | "정리";
/** GET /personas — personas.py PersonaSummary */
export interface PersonaSummary {
code: string;
display_name: string;
difficulty: "easy" | "moderate" | "hard" | string;
theory_target: string[];
demographics: Record<string, unknown>;
presenting_summary: string;
voice_preset: string | null;
source: string;
degraded: boolean;
}
/** POST /sessions — sessions.py SessionStartResponse */
export interface SessionStartResponse {
session_id: string;
case_id: string;
session_no: number;
stage: SessionStage;
effective_openness: number;
recall_summary: string | null;
degraded: boolean;
}
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
@ -157,17 +205,144 @@ export interface SessionEndResponse {
digest_pending: boolean;
}
export interface LearnerSessionSummary {
session_id: string;
persona_code: string;
persona_name: string;
session_no: number;
status: "active" | "ended";
stage: string;
turn_count: number;
learner_turn_count: number;
client_turn_count: number;
started_at: string;
ended_at: string | null;
review_ready: boolean;
}
export interface LearnerSessionsResponse {
source: string;
sessions: LearnerSessionSummary[];
}
export interface SessionDetailTurn {
turn_seq: number;
speaker: "learner" | "client";
stage: string;
text: string;
created_at: string;
}
export interface SessionDetailResponse {
session_id: string;
case_id: string;
persona_code: string;
persona_name: string;
theory_mode: string;
status: "active" | "ended";
stage: SessionStage;
effective_openness: number;
started_at: string;
ended_at: string | null;
turns: SessionDetailTurn[];
review_ready: boolean;
}
export interface ReviewClient {
name: string;
initial: string;
persona: string;
}
export interface ReviewTechnique {
kind: string;
label: string;
}
export interface ReviewNote {
author: "ai" | "instructor" | string;
tone: "good" | "watch";
title: string;
body: string;
quote?: string | null;
}
export interface ReviewTurn {
id: string;
ts: string;
speaker: "learner" | "client";
who: string;
text: string;
techniques: ReviewTechnique[];
note?: ReviewNote | null;
}
export interface ReviewPhaseSegment {
key: string;
label: string;
weight: number;
}
export interface ReviewValencePoint {
t: number;
v: number;
}
export interface ReviewRubricRow {
name: string;
cluster: string;
ratio: number;
quality: "good" | "watch";
freq: string;
}
export interface ReviewPoint {
title: string;
body: string;
jumpTo?: string | null;
}
export interface SessionReviewResponse {
session_id: string;
client: ReviewClient;
date: string;
durationLabel: string;
durationSeconds: number;
reachedPhase: string;
sessionSignal: string;
supervisorState: string;
supervisorName: string;
summary: string;
phases: ReviewPhaseSegment[];
phaseAxis: string[];
valenceAxis: string[];
clientValence: ReviewValencePoint[];
counselorBaseline: ReviewValencePoint[];
turns: ReviewTurn[];
rubric: ReviewRubricRow[];
goodMoments: ReviewPoint[];
growthPoints: ReviewPoint[];
nextLine?: string | null;
clientFeedback?: string | null;
audioUrl?: string | null;
pdfExportUrl?: string | null;
degraded: boolean;
reviewReady: boolean;
}
/* =====================================================================
SSE GET /sessions/{id}/stream
SSE POST /sessions/{id}/stream
(sse_starlette): "token" | "done" | "ping" | "safety" | "error"
EventSource same-origin BFF .
EventSource fetch stream .
===================================================================== */
export interface SessionStreamHandlers {
/** 서버가 요청을 수락했고 learner turn 이 저장 가능한 지점 */
onOpen?: () => void;
/** 내담자 AI 토큰 1조각 */
onToken?: (chunk: string) => void;
/** 스트림 정상 종료 */
onDone?: (data: { session_id: string }) => void;
onDone?: (data: SessionStreamDone) => void;
/** 안전(위기) 신호 */
onSafety?: (data: unknown) => void;
/** 에러 이벤트(백엔드 EngineError) 또는 연결 오류 */
@ -176,9 +351,12 @@ export interface SessionStreamHandlers {
onPing?: () => void;
}
export interface SessionStreamHandle {
/** 스트림 종료(EventSource close) */
close: () => void;
export interface SessionStreamDone {
session_id: string;
stage?: SessionStage;
effective_openness?: number;
turn_seq?: number;
safety_flagged?: boolean;
}
function safeParse(data: string): unknown {
@ -190,61 +368,285 @@ function safeParse(data: string): unknown {
}
/**
* AI SSE .
* @returns close() . close .
* AI SSE .
* learner turn token/done .
*/
export function openSessionStream(
export async function openSessionStream(
sessionId: string,
text: string,
handlers: SessionStreamHandlers,
): SessionStreamHandle {
const url = joinUrl(`/sessions/${encodeURIComponent(sessionId)}/stream`);
// withCredentials: same-origin 쿠키 전송 (BFF). cross-origin SSE 는 CORS 필요.
const es = new EventSource(url, { withCredentials: true });
): Promise<SessionStreamDone> {
const res = await fetch(joinUrl(`/sessions/${encodeURIComponent(sessionId)}/stream`), {
method: "POST",
credentials: "include",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify({ text }),
});
// addEventListener 의 커스텀 이벤트 리스너 시그니처는 Event 를 받으므로
// MessageEvent 로 안전하게 좁힌다(.data 접근).
const dataOf = (ev: Event): string | undefined =>
(ev as MessageEvent).data as string | undefined;
if (!res.ok) {
throw await parseError(res);
}
if (!res.body) {
throw new ApiError(res.status, "스트림 응답 본문이 없습니다.");
}
es.addEventListener("token", (ev: Event) => {
const data = dataOf(ev);
if (data != null) handlers.onToken?.(data);
});
es.addEventListener("done", (ev: Event) => {
const parsed = safeParse(dataOf(ev) ?? "{}") as { session_id?: string };
handlers.onDone?.({ session_id: parsed.session_id ?? sessionId });
es.close();
});
es.addEventListener("safety", (ev: Event) => {
handlers.onSafety?.(safeParse(dataOf(ev) ?? "null"));
});
es.addEventListener("ping", () => {
handlers.onPing?.();
});
es.addEventListener("error", (ev: Event) => {
// sse_starlette 의 명시적 error 이벤트는 data 를 가짐.
// 브라우저 연결 오류 이벤트는 data 가 없음 → 일반 연결 오류로 처리.
const data = dataOf(ev);
if (data) {
const parsed = safeParse(data) as { detail?: string };
handlers.onError?.({ detail: parsed.detail ?? "stream error" });
} else if (es.readyState === EventSource.CLOSED) {
handlers.onError?.({ detail: "스트림 연결이 종료되었습니다." });
handlers.onOpen?.();
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventName = "message";
let dataLines: string[] = [];
let donePayload: SessionStreamDone | null = null;
let streamError: ApiError | null = null;
const dispatch = () => {
if (!eventName && dataLines.length === 0) return;
const data = dataLines.join("\n");
const event = eventName || "message";
eventName = "message";
dataLines = [];
if (event === "token") {
handlers.onToken?.(data);
return;
}
if (event === "done") {
const parsed = safeParse(data || "{}") as Partial<SessionStreamDone>;
donePayload = {
session_id: parsed.session_id ?? sessionId,
stage: parsed.stage,
effective_openness: parsed.effective_openness,
turn_seq: parsed.turn_seq,
safety_flagged: parsed.safety_flagged,
};
handlers.onDone?.(donePayload);
return;
}
if (event === "safety") {
handlers.onSafety?.(safeParse(data || "null"));
return;
}
if (event === "ping") {
handlers.onPing?.();
return;
}
if (event === "error") {
const parsed = safeParse(data || "{}") as { detail?: string };
const detail = parsed.detail ?? "stream error";
handlers.onError?.({ detail });
streamError = new ApiError(503, detail, parsed);
}
});
return {
close: () => es.close(),
};
const processLine = (line: string) => {
if (line === "") {
dispatch();
return;
}
if (line.startsWith(":")) return;
const idx = line.indexOf(":");
const field = idx === -1 ? line : line.slice(0, idx);
const rawValue = idx === -1 ? "" : line.slice(idx + 1);
const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
if (field === "event") eventName = value;
else if (field === "data") dataLines.push(value);
};
const processBuffer = (final = false) => {
const lines = buffer.split(/\r?\n/);
buffer = final ? "" : (lines.pop() ?? "");
for (const line of lines) processLine(line.endsWith("\r") ? line.slice(0, -1) : line);
if (final && buffer) processLine(buffer);
if (final && dataLines.length > 0) dispatch();
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
processBuffer();
if (streamError) break;
}
buffer += decoder.decode();
processBuffer(true);
if (streamError) throw streamError;
return donePayload ?? { session_id: sessionId };
}
/* === 세션 API 헬퍼 (Features 단계 Session 페이지가 사용) === */
export const personaApi = {
list: () => api.get<PersonaSummary[]>("/personas"),
};
export const sessionApi = {
list: () => api.get<LearnerSessionsResponse>("/sessions"),
get: (sessionId: string) =>
api.get<SessionDetailResponse>(`/sessions/${encodeURIComponent(sessionId)}`),
start: (persona_code: string, theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic") =>
api.post<SessionStartResponse>("/sessions", { persona_code, theory_mode }),
turn: (sessionId: string, text: string) =>
api.post<TurnResponse>(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }),
end: (sessionId: string) =>
api.post<SessionEndResponse>(`/sessions/${encodeURIComponent(sessionId)}/end`),
review: (sessionId: string) =>
api.get<SessionReviewResponse>(`/sessions/${encodeURIComponent(sessionId)}/review`),
stream: openSessionStream,
};
export type AdminHealthStatus = "ok" | "degraded" | "down";
export interface AdminServiceHealth {
key: string;
name: string;
status: AdminHealthStatus;
detail: string;
metric: string;
load: number;
}
export interface AdminHealthResponse {
status: AdminHealthStatus;
environment: string;
engine_mode: string;
services: AdminServiceHealth[];
}
export const adminApi = {
health: () => api.get<AdminHealthResponse>("/admin/health"),
};
export interface AdminManagedUser {
user_id: string;
email: string;
display_name: string;
role: "learner" | "teacher" | "admin";
cohort_ids: string[];
affiliation: string;
active_sessions: number;
created_at: number;
last_seen_at: number;
source: "database" | "server_session_registry";
}
export interface AdminUsersResponse {
source: "database" | "server_session_registry";
durable: boolean;
users: AdminManagedUser[];
}
export type AdminUserCreateRequest = Pick<
AdminManagedUser,
"email" | "display_name" | "role" | "affiliation" | "cohort_ids"
>;
export const adminUsersApi = {
list: () => api.get<AdminUsersResponse>("/admin/users"),
create: (body: AdminUserCreateRequest) =>
apiFetch<AdminManagedUser>("/admin/users", { method: "POST", body }),
update: (
userId: string,
body: Partial<Pick<AdminManagedUser, "display_name" | "role" | "affiliation" | "cohort_ids">>,
) => apiFetch<AdminManagedUser>(`/admin/users/${encodeURIComponent(userId)}`, {
method: "PATCH",
body,
}),
deactivate: (userId: string) =>
apiFetch<{ ok: boolean; user_id: string }>(`/admin/users/${encodeURIComponent(userId)}`, {
method: "DELETE",
}),
};
export interface TeacherSessionSummary {
session_id: string;
learner_id: string;
learner_label: string;
persona_code: string;
persona_name: string;
session_no: number;
status: "active" | "ended" | string;
stage: string;
turn_count: number;
learner_turn_count: number;
client_turn_count: number;
started_at: string;
ended_at: string | null;
}
export interface TeacherDashboardResponse {
source: string;
cohort_label: string;
total_learners: number;
active_sessions: number;
ended_sessions: number;
pending_reviews: TeacherSessionSummary[];
recent_sessions: TeacherSessionSummary[];
message: string;
}
export const teacherApi = {
dashboard: () => api.get<TeacherDashboardResponse>("/teacher/dashboard"),
};
export interface UserProfileResponse {
user_id: string;
email: string;
display_name: string;
role: RoleString;
cohort_ids: string[];
affiliation: string;
}
export interface NotificationPreferences {
session_done: boolean;
safety_signal: boolean;
learner_progress: boolean;
product_news: boolean;
}
export interface UserPreferencesResponse {
theme: "system" | "light" | "dark" | string;
voice_preset_id: string;
voice_rate: number;
notifications: NotificationPreferences;
}
export interface VoicePresetResponse {
id: string;
voice_id: string;
name: string;
desc: string;
persona_hint: string;
}
export type RoleString = "learner" | "teacher" | "admin" | string;
export const userApi = {
me: () => api.get<UserProfileResponse>("/users/me"),
updateMe: (body: { display_name?: string; affiliation?: string }) =>
apiFetch<UserProfileResponse>("/users/me", { method: "PATCH", body }),
preferences: () => api.get<UserPreferencesResponse>("/users/me/preferences"),
updatePreferences: (body: Partial<UserPreferencesResponse>) =>
apiFetch<UserPreferencesResponse>("/users/me/preferences", { method: "PATCH", body }),
voicePresets: () => api.get<VoicePresetResponse[]>("/users/me/voice-presets"),
};
export interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
updated_by: string | null;
updated_at: number | null;
durable: boolean;
source: "database" | "runtime_cache" | "runtime_default" | string;
}
export const adminEngineApi = {
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
update: (body: Partial<Pick<AdminEngineConfigResponse, "engine_mode" | "engine_url" | "model">>) =>
apiFetch<AdminEngineConfigResponse>("/admin/engine-config", { method: "PATCH", body }),
};

View file

@ -1,13 +1,3 @@
/* =====================================================================
Vignette AuthContext
- user/role , login/logout.
- mock ( ) + /auth/me ( ).
- role <body data-role>·data-theme tokens.css §6.2 accent .
Role enum(deps.py): learner | teacher | admin.
accent(DESIGN_CONCEPT §6.2): learner | instructor | admin.
teacher data-role="instructor" (-).
===================================================================== */
import {
createContext,
useCallback,
@ -17,16 +7,14 @@ import {
useState,
type ReactNode,
} from "react";
import { api, ApiError, type MeResponse } from "./api";
import { api, type MeResponse } from "./api";
/** 인증·인가 도메인 역할 (백엔드 deps.py Role 미러). */
export type Role = "learner" | "teacher" | "admin";
/** tokens.css §6.2 accent 스왑용 data-role 값. */
export type DesignRole = "learner" | "instructor" | "admin";
export interface AuthUser {
userId: string;
email: string;
name: string;
role: Role;
cohortIds: string[];
@ -35,26 +23,19 @@ export interface AuthUser {
export interface AuthContextValue {
user: AuthUser | null;
role: Role | null;
/** 부트스트랩(/auth/me) 진행 여부 — 가드 라우트의 깜빡임 방지 */
loading: boolean;
/** 개발용 mock 로그인: 역할 선택으로 즉시 인증 상태 진입 */
login: (role: Role, opts?: { name?: string; userId?: string }) => void;
/** 로그아웃 — 서버 세션 무효화 시도 후 로컬 상태 클리어 */
login: (role: Role, opts?: { email?: string; displayName?: string }) => Promise<AuthUser>;
logout: () => Promise<void>;
}
const STORAGE_KEY = "vignette.dev-auth";
/** API role → 디자인 data-role 매핑. */
export function designRoleOf(role: Role): DesignRole {
return role === "teacher" ? "instructor" : role;
}
/** 역할별 한국어 컨텍스트 라벨 (톱바 좌측, §6.3). */
export function roleLabel(role: Role): string {
switch (role) {
case "learner":
return "학습 대시보드";
return "학습자 공간";
case "teacher":
return "교수 콘솔";
case "admin":
@ -62,7 +43,6 @@ export function roleLabel(role: Role): string {
}
}
/** 역할 진입 기본 경로. */
export function roleHomePath(role: Role): string {
switch (role) {
case "learner":
@ -74,50 +54,42 @@ export function roleHomePath(role: Role): string {
}
}
const ROLE_DEFAULT_NAME: Record<Role, string> = {
learner: "김수련",
teacher: "이교수",
admin: "운영자",
const DEV_EMAIL_BY_ROLE: Record<Role, string> = {
learner: "learner@hs.ac.kr",
teacher: "teacher@hs.ac.kr",
admin: "admin@twentyoz.kr",
};
const DEV_NAME_BY_ROLE: Record<Role, string> = {
learner: "학습자",
teacher: "교수자",
admin: "관리자",
};
const AuthContext = createContext<AuthContextValue | null>(null);
function loadStored(): AuthUser | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as AuthUser;
if (parsed && typeof parsed.role === "string") return parsed;
} catch {
/* 무시 */
}
return null;
function userFromMe(me: MeResponse): AuthUser {
return {
userId: me.user_id,
email: me.email,
name: me.display_name || me.email || me.user_id,
role: (me.role as Role) ?? "learner",
cohortIds: me.cohort_ids ?? [],
};
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(() => loadStored());
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
// 부트스트랩: 실서버 세션이 있으면 우선. 없으면(401/네트워크오류) 저장된 mock 유지.
useEffect(() => {
let alive = true;
(async () => {
try {
const me = await api.get<MeResponse>("/auth/me");
if (!alive) return;
const serverUser: AuthUser = {
userId: me.user_id,
name: me.user_id,
role: (me.role as Role) ?? "learner",
cohortIds: me.cohort_ids ?? [],
};
setUser(serverUser);
} catch (err) {
// 401(미인증) 또는 백엔드 미가동 → mock/로그아웃 상태 유지(에러 아님).
if (!(err instanceof ApiError) && !(err instanceof TypeError)) {
// 예기치 못한 오류는 콘솔로만 (UX 차단 안 함)
console.warn("[auth] /auth/me bootstrap failed", err);
}
if (alive) setUser(userFromMe(me));
} catch {
if (alive) setUser(null);
} finally {
if (alive) setLoading(false);
}
@ -127,42 +99,28 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
}, []);
// role → <body data-role> 반영 (accent 스왑). 미인증이면 속성 제거.
useEffect(() => {
const body = document.body;
if (user) {
body.setAttribute("data-role", designRoleOf(user.role));
} else {
body.removeAttribute("data-role");
}
if (user) body.setAttribute("data-role", designRoleOf(user.role));
else body.removeAttribute("data-role");
}, [user]);
const login = useCallback<AuthContextValue["login"]>((role, opts) => {
const next: AuthUser = {
userId: opts?.userId ?? `dev-${role}`,
name: opts?.name ?? ROLE_DEFAULT_NAME[role],
const login = useCallback<AuthContextValue["login"]>(async (role, opts) => {
const me = await api.post<MeResponse>("/auth/dev-login", {
email: opts?.email ?? DEV_EMAIL_BY_ROLE[role],
role,
cohortIds: [],
};
display_name: opts?.displayName ?? DEV_NAME_BY_ROLE[role],
});
const next = userFromMe(me);
setUser(next);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* 저장 실패 무시 */
}
return next;
}, []);
const logout = useCallback<AuthContextValue["logout"]>(async () => {
try {
await api.post("/auth/logout");
} catch {
// 서버 미가동/스텁이어도 로컬 클리어는 진행
}
setUser(null);
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* 무시 */
} finally {
setUser(null);
}
}, []);
@ -177,7 +135,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth 는 <AuthProvider> 내부에서만 사용할 수 있습니다.");
throw new Error("useAuth must be used inside AuthProvider");
}
return ctx;
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,340 +1,540 @@
/* =====================================================================
Login ( ).
docs/mockups/login.html . Vignette / .
OAuth(/) = mock. (learner/teacher/admin) mock .
철칙: border-left 0 · 0( inline SVG/Icon) · / · weight/tint/kicker/dot.
===================================================================== */
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Icon } from "../components/ui/Icon";
import { useAuth, roleHomePath, type Role } from "../lib/auth";
import { roleHomePath, useAuth, type Role } from "../lib/auth";
import { apiUrl, authApi, type AuthConfigResponse } from "../lib/api";
const OAUTH_NOT_CONFIGURED_MESSAGE =
"Google 로그인이 아직 연결되지 않았습니다. 관리자에게 OAuth 클라이언트 설정을 요청하세요.";
const OAUTH_FAILED_MESSAGE =
"Google 로그인 흐름을 완료하지 못했습니다. 다시 시도하거나 관리자에게 설정 확인을 요청하세요.";
const LOCAL_OAUTH_UNAVAILABLE_MESSAGE =
"로컬 개발 주소에서는 Google OAuth 콜백이 공개 API로 돌아가므로 로컬 테스트 계정으로 로그인하세요.";
function oauthMessage(reason: string | null): string | null {
if (!reason) return null;
if (reason === "not_configured") return OAUTH_NOT_CONFIGURED_MESSAGE;
if (reason === "domain_not_allowed") {
return "승인된 이메일 도메인의 Google 계정만 사용할 수 있습니다.";
}
if (reason === "inactive_user") {
return "비활성화된 계정입니다. 관리자에게 계정 상태 확인을 요청하세요.";
}
return OAUTH_FAILED_MESSAGE;
}
const ROLE_OPTIONS: { role: Role; label: string; desc: string; dotClass: string }[] = [
{ role: "learner", label: "학습자", desc: "상담을 연습합니다", dotClass: "learner" },
{ role: "teacher", label: "교수자", desc: "수련생을 감독합니다", dotClass: "instructor" },
{ role: "admin", label: "관리자", desc: "운영을 점검합니다", dotClass: "admin" },
{ role: "learner", label: "학습자", desc: "연습 공간으로 이동", dotClass: "learner" },
{ role: "teacher", label: "교수자", desc: "담당 학습자 관리", dotClass: "teacher" },
{ role: "admin", label: "관리자", desc: "운영 설정과 감사", dotClass: "admin" },
];
function isLocalHostname(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
}
function isLocalRedirectUri(uri: string | undefined): boolean {
if (!uri) return false;
try {
return isLocalHostname(new URL(uri).hostname);
} catch {
return false;
}
}
export default function Login() {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [selected, setSelected] = useState<Role>("learner");
const [pending, setPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [authConfig, setAuthConfig] = useState<AuthConfigResponse | null>(null);
const [authConfigError, setAuthConfigError] = useState<string | null>(null);
// mock 로그인: 선택 역할로 즉시 인증 진입 → 역할 홈으로.
const enter = (role: Role) => {
login(role);
navigate(roleHomePath(role), { replace: true });
const requestedPath =
typeof location.state === "object" &&
location.state !== null &&
"from" in location.state &&
typeof location.state.from === "string"
? location.state.from
: "/";
useEffect(() => {
const oauthState = new URLSearchParams(location.search).get("oauth");
setLoginError(oauthMessage(oauthState));
}, [location.search]);
useEffect(() => {
let alive = true;
void authApi
.config()
.then((next) => {
if (alive) setAuthConfig(next);
})
.catch((err) => {
if (alive) {
setAuthConfigError(
err instanceof Error ? err.message : "로그인 설정을 확인하지 못했습니다.",
);
}
});
return () => {
alive = false;
};
}, []);
const oauthChecking = authConfig === null && authConfigError === null;
const devLoginReady = import.meta.env.DEV && authConfig?.dev_login_enabled === true;
const localOrigin =
typeof window !== "undefined" && isLocalHostname(window.location.hostname);
const localOAuthUnavailable =
localOrigin &&
devLoginReady &&
authConfig?.google_oauth_configured === true &&
!isLocalRedirectUri(authConfig.redirect_uri);
const oauthReady =
authConfig?.google_oauth_configured === true && !localOAuthUnavailable;
const allowedDomains = authConfig?.allowed_email_domains ?? [];
const primaryDomainLabel = localOAuthUnavailable
? "로컬은 테스트 계정 사용"
: allowedDomains[0]
? `@${allowedDomains[0]}`
: oauthChecking
? "도메인 확인 중"
: "승인 도메인 계정";
const secondaryDomainLabel = localOAuthUnavailable
? "공개 주소에서 사용"
: allowedDomains[1]
? `@${allowedDomains[1]}`
: allowedDomains[0]
? "승인된 Google 계정"
: "관리자 설정 필요";
const startOAuth = () => {
if (!oauthReady) {
setLoginError(
localOAuthUnavailable
? LOCAL_OAUTH_UNAVAILABLE_MESSAGE
: (authConfigError ?? OAUTH_NOT_CONFIGURED_MESSAGE),
);
return;
}
window.location.assign(
apiUrl(`/auth/login?provider=google&next=${encodeURIComponent(requestedPath)}`),
);
};
const enterDev = async (role: Role) => {
setPending(true);
setLoginError(null);
try {
const signedIn = await login(role);
navigate(roleHomePath(signedIn.role), { replace: true });
} catch (err) {
setLoginError(err instanceof Error ? err.message : "로그인에 실패했습니다.");
} finally {
setPending(false);
}
};
return (
<div className="lg-root">
<main className="lg-root">
<style>{LOGIN_CSS}</style>
{/* 공통 헤더 */}
<header className="lg-topbar">
<span className="lg-wordmark">
<span className="lg-mark">
<svg viewBox="0 0 26 26" width={24} height={24} fill="none" aria-hidden="true">
<circle cx="13" cy="13" r="11" stroke="var(--accent)" strokeWidth="2" />
<section className="lg-brand" aria-label="Vignette">
<div className="lg-wordmark">
<span className="lg-mark" aria-hidden="true">
<svg viewBox="0 0 26 26" width={26} height={26} fill="none">
<circle cx="13" cy="13" r="11" stroke="currentColor" strokeWidth="2" />
<path
d="M8 14.5c1.4 1.7 3 2.5 5 2.5s3.6-.8 5-2.5"
stroke="var(--accent)"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<circle cx="13" cy="9" r="1.6" fill="var(--clay)" />
</svg>
</span>
<span className="nm">Vignette</span>
<span className="lg-sub"> </span>
</span>
<span className="lg-role-pill">
<span className="d" aria-hidden="true" />
</span>
</header>
<span>Vignette</span>
</div>
<div className="lg-shell">
{/* LEFT: 브랜드 + 한 문장 가치제안 */}
<section className="lg-brand">
<div className="lg-brand-top">
<span className="lg-kicker">
<span className="d" aria-hidden="true" />
</span>
</div>
<div className="lg-copy">
<span className="lg-kicker">
<span className="d" aria-hidden="true" />
</span>
<h1> , .</h1>
<p>
Vignette는 , , .
.
</p>
</div>
<div className="lg-lede">
<h1>
,
<br />
<span className="em"> </span>.
</h1>
<p>
,
AI가 .
</p>
</div>
<div className="lg-policy">
<span>
<Icon name="shield" size={17} />
</span>
{allowedDomains.length ? (
allowedDomains.map((domain) => <b key={domain}>{domain}</b>)
) : (
<b>{oauthChecking ? "확인 중" : "설정 필요"}</b>
)}
</div>
</section>
<div className="lg-brand-foot">
<div className="lg-roles">
<span className="r">
<span className="d learner" aria-hidden="true" />
</span>
<span className="sep" aria-hidden="true" />
<span className="r">
<span className="d instructor" aria-hidden="true" />
</span>
<span className="sep" aria-hidden="true" />
<span className="r">
<span className="d admin" aria-hidden="true" />
</span>
</div>
<p className="lg-ethic">
· .
.
</p>
</div>
</section>
<section className="lg-enter" aria-label="로그인">
<div className="lg-panel">
<span className="lg-kicker">
<span className="d" aria-hidden="true" />
</span>
<h2></h2>
<p className="lg-lead">
Google .
</p>
{/* RIGHT: OAuth 진입 (mock) */}
<section className="lg-enter">
<div className="lg-panel">
<span className="lg-panel-kicker">
<span className="d" aria-hidden="true" />
</span>
<h2> .</h2>
<p className="lg-lead">
.
</p>
<div className="lg-oauth">
{/* 한신대 계정 = 주 경로 (선택된 역할로 mock 로그인) */}
<button className="lg-obtn primary" type="button" onClick={() => enter(selected)}>
<span className="ic">
<Icon name="school" size={19} strokeWidth={1.8} />
</span>
<span className="txt">
<span className="sub">@hs.ac.kr </span>
</span>
<span className="arrow">
<Icon name="chevron-right" size={18} strokeWidth={2} />
</span>
</button>
{/* Google = 보조 경로 (mock) */}
<button className="lg-obtn secondary" type="button" onClick={() => enter(selected)}>
<span className="ic">
<Icon name="google" size={19} />
</span>
<span className="txt">
Google
<span className="sub"> Google </span>
</span>
<span className="arrow">
<Icon name="chevron-right" size={18} strokeWidth={2} />
</span>
</button>
</div>
{/* 역할 선택 (개발용 mock) */}
<div className="lg-divider">
<span className="ln" aria-hidden="true" />
<span className="lb"> </span>
<span className="ln" aria-hidden="true" />
</div>
<div className="lg-rolepick" role="radiogroup" aria-label="역할 선택">
{ROLE_OPTIONS.map((opt) => (
<button
key={opt.role}
type="button"
role="radio"
aria-checked={selected === opt.role}
className={"lg-roleopt" + (selected === opt.role ? " is-sel" : "")}
onClick={() => setSelected(opt.role)}
>
<span className={"d " + opt.dotClass} aria-hidden="true" />
<span className="rt">
<b>{opt.label}</b>
<span className="rd">{opt.desc}</span>
</span>
{selected === opt.role ? (
<span className="ck">
<Icon name="check" size={15} strokeWidth={2.4} />
</span>
) : null}
</button>
))}
</div>
{/* 교육용 비치료 도구 고지 (info tint, dot+텍스트) */}
<div className="lg-notice">
<div className="lg-actions">
<button
className="lg-obtn primary"
type="button"
onClick={startOAuth}
disabled={!oauthReady}
>
<span className="ic">
<Icon name="info" size={16} strokeWidth={1.8} />
<Icon name="school" size={19} strokeWidth={1.8} />
</span>
<span className="tx">
<b> </b>. ·
, ·· .
<span className="txt">
Google
<span className="sub">{primaryDomainLabel}</span>
</span>
<Icon name="chevron-right" size={18} strokeWidth={2} />
</button>
<button
className="lg-obtn secondary"
type="button"
onClick={startOAuth}
disabled={!oauthReady}
>
<span className="ic">
<Icon name="google" size={19} />
</span>
<span className="txt">
Google
<span className="sub">{secondaryDomainLabel}</span>
</span>
<Icon name="chevron-right" size={18} strokeWidth={2} />
</button>
</div>
{!oauthReady ? (
<div className="lg-config" role="status">
<Icon name={authConfigError ? "alert" : "info"} size={17} />
<span>
{localOAuthUnavailable
? LOCAL_OAUTH_UNAVAILABLE_MESSAGE
: oauthChecking
? "Google 로그인 설정을 확인하는 중입니다."
: "Google 로그인이 아직 연결되지 않았습니다. 관리자에게 OAuth 클라이언트 설정을 요청하세요."}
</span>
</div>
) : null}
<p className="lg-help">
? <a href="#help"> </a>
<br />
.
</p>
</div>
</section>
</div>
</div>
{devLoginReady ? (
<div className="lg-dev">
<div className="lg-devhead">
<span> </span>
<small> </small>
</div>
<div className="lg-rolepick" role="radiogroup" aria-label="로컬 테스트 역할">
{ROLE_OPTIONS.map((opt) => (
<button
key={opt.role}
type="button"
role="radio"
aria-checked={selected === opt.role}
className={`lg-roleopt ${selected === opt.role ? "is-sel" : ""}`}
onClick={() => setSelected(opt.role)}
>
<span className={`d ${opt.dotClass}`} aria-hidden="true" />
<span>
<b>{opt.label}</b>
<small>{opt.desc}</small>
</span>
{selected === opt.role ? <Icon name="check" size={15} /> : null}
</button>
))}
</div>
<button
className="lg-devbtn"
type="button"
onClick={() => void enterDev(selected)}
disabled={pending}
>
{pending ? "테스트 계정 확인 중" : "로컬 테스트 계정으로 계속"}
</button>
{loginError ? <p className="lg-error">{loginError}</p> : null}
</div>
) : null}
{!devLoginReady && loginError ? <p className="lg-error">{loginError}</p> : null}
<p className="lg-note">
. , , .
</p>
</div>
</section>
</main>
);
}
const LOGIN_CSS = `
.lg-root{min-height:100vh;display:flex;flex-direction:column;background:var(--bg-app);}
/* 헤더 */
.lg-topbar{
height:56px;flex:none;background:var(--bg-surface);border-bottom:1px solid var(--hair);
display:flex;align-items:center;gap:var(--sp-5);padding:0 var(--sp-7);
.lg-root{
min-height:100dvh;
display:grid;
grid-template-columns:minmax(0,1fr) minmax(360px,480px);
background:var(--bg-app);
color:var(--text-strong);
}
.lg-wordmark{display:flex;align-items:center;gap:9px;}
.lg-mark{display:flex;align-items:center;}
.lg-wordmark .nm{font-size:18px;font-weight:700;color:var(--text-strong);letter-spacing:-0.02em;}
.lg-sub{font-family:var(--font-num);font-size:11px;font-weight:600;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-muted);}
.lg-role-pill{
margin-left:auto;display:inline-flex;align-items:center;gap:7px;
background:var(--accent-tint);color:var(--accent-deep);
font-size:12px;font-weight:600;padding:5px 11px;border-radius:999px;
}
.lg-role-pill .d{width:6px;height:6px;border-radius:50%;background:var(--accent);}
/* 2분할 */
.lg-shell{flex:1;display:grid;grid-template-columns:1.05fr 1fr;min-height:0;}
/* LEFT */
.lg-brand{
background:var(--bg-stage);color:#E8EEF2;position:relative;overflow:hidden;
display:flex;flex-direction:column;justify-content:space-between;
padding:var(--sp-9) var(--sp-8);
min-width:0;
display:flex;
flex-direction:column;
justify-content:space-between;
gap:var(--sp-7);
padding:var(--sp-7);
background:var(--bg-stage);
color:#edf4f2;
}
.lg-brand::before{
content:"";position:absolute;width:520px;height:520px;border-radius:50%;
top:-160px;right:-180px;
background:radial-gradient(circle, rgba(95,150,139,.20) 0%, transparent 68%);
animation:lgAura 9s ease-in-out infinite;
.lg-wordmark{
display:flex;
align-items:center;
gap:10px;
font-size:19px;
font-weight:700;
letter-spacing:0;
color:#edf4f2;
}
.lg-brand::after{
content:"";position:absolute;width:380px;height:380px;border-radius:50%;
bottom:-140px;left:-120px;
background:radial-gradient(circle, rgba(176,115,92,.13) 0%, transparent 70%);
}
@keyframes lgAura{0%,100%{opacity:.85;transform:scale(1)}50%{opacity:1;transform:scale(1.04)}}
.lg-brand-top{position:relative;z-index:1;}
.lg-mark{display:grid;place-items:center;color:var(--accent-bright);}
.lg-copy{max-width:620px;}
.lg-kicker{
font-family:var(--font-num);font-size:12px;font-weight:600;
letter-spacing:0.12em;text-transform:uppercase;color:rgba(232,238,242,.62);
display:inline-flex;align-items:center;gap:9px;
display:inline-flex;
align-items:center;
gap:8px;
font-family:var(--font-num);
font-size:12px;
font-weight:700;
letter-spacing:.08em;
text-transform:uppercase;
color:var(--accent-bright);
}
.lg-kicker .d{width:6px;height:6px;border-radius:50%;background:var(--accent-bright);}
.lg-lede{position:relative;z-index:1;max-width:480px;}
.lg-lede h1{font-size:38px;line-height:1.32;font-weight:700;letter-spacing:-0.02em;color:#F2F6F6;}
.lg-lede h1 .em{color:var(--accent-bright);font-weight:700;}
.lg-lede p{margin-top:var(--sp-5);font-size:16px;line-height:1.7;color:rgba(232,238,242,.7);max-width:420px;}
.lg-brand-foot{position:relative;z-index:1;display:flex;flex-direction:column;gap:var(--sp-4);}
.lg-roles{display:flex;align-items:center;gap:var(--sp-5);}
.lg-roles .r{display:inline-flex;align-items:center;gap:9px;font-size:13px;color:rgba(232,238,242,.66);}
.lg-roles .r .d{width:7px;height:7px;border-radius:50%;}
.lg-roles .r .d.learner{background:var(--accent-bright);}
.lg-roles .r .d.instructor{background:#5478C4;}
.lg-roles .r .d.admin{background:#9DA1AE;}
.lg-roles .sep{width:1px;height:13px;background:rgba(232,238,242,.18);}
.lg-ethic{font-size:13px;line-height:1.6;color:rgba(232,238,242,.5);border-top:1px solid rgba(232,238,242,.12);padding-top:var(--sp-4);max-width:440px;}
/* RIGHT */
.lg-enter{background:var(--bg-app);display:flex;align-items:center;justify-content:center;padding:var(--sp-8) var(--sp-7);}
.lg-panel{width:100%;max-width:392px;}
.lg-panel-kicker{
font-family:var(--font-num);font-size:12px;font-weight:600;letter-spacing:0.08em;color:var(--accent);
display:inline-flex;align-items:center;gap:8px;margin-bottom:var(--sp-4);
.lg-kicker .d{width:6px;height:6px;border-radius:50%;background:currentColor;}
.lg-copy h1{
margin:var(--sp-4) 0 0;
max-width:640px;
font-size:56px;
line-height:1.12;
letter-spacing:0;
font-weight:760;
}
.lg-panel-kicker .d{width:6px;height:6px;border-radius:50%;background:var(--accent-bright);}
.lg-panel h2{font-size:24px;font-weight:700;letter-spacing:-0.015em;color:var(--text-strong);}
.lg-lead{margin-top:10px;font-size:15px;line-height:1.65;color:var(--text-body);max-width:340px;}
.lg-oauth{margin-top:var(--sp-7);display:flex;flex-direction:column;gap:var(--sp-3);}
.lg-copy p{
margin:var(--sp-5) 0 0;
max-width:560px;
color:rgba(237,244,242,.72);
font-size:17px;
line-height:1.75;
}
.lg-policy{
display:flex;
align-items:center;
flex-wrap:wrap;
gap:10px;
color:rgba(237,244,242,.66);
font-size:13px;
}
.lg-policy span,.lg-policy b{
display:inline-flex;
align-items:center;
gap:7px;
}
.lg-policy b{
color:#edf4f2;
background:rgba(255,255,255,.08);
border:1px solid rgba(255,255,255,.12);
border-radius:999px;
padding:5px 10px;
font-weight:650;
}
.lg-enter{
min-width:0;
display:flex;
align-items:center;
justify-content:center;
padding:var(--sp-6);
}
.lg-panel{
width:100%;
max-width:400px;
background:var(--bg-surface);
border:1px solid var(--border-subtle);
border-radius:var(--radius-lg);
box-shadow:var(--shadow-sm);
padding:var(--sp-6);
}
.lg-panel h2{
margin:var(--sp-3) 0 0;
font-size:28px;
line-height:1.25;
letter-spacing:0;
}
.lg-lead{
margin:10px 0 0;
color:var(--text-body);
font-size:14px;
line-height:1.65;
}
.lg-actions{display:flex;flex-direction:column;gap:var(--sp-3);margin-top:var(--sp-6);}
.lg-obtn{
width:100%;display:flex;align-items:center;gap:14px;padding:14px 18px;border-radius:var(--radius);
font-family:var(--font-sans);font-size:15px;font-weight:600;cursor:pointer;text-align:left;
transition:border-color .16s var(--ease-out),background .16s var(--ease-out),box-shadow .16s var(--ease-out);
width:100%;
min-height:58px;
display:grid;
grid-template-columns:36px minmax(0,1fr) 18px;
align-items:center;
gap:13px;
border-radius:var(--radius);
padding:11px 14px;
font-family:var(--font-sans);
font-size:15px;
font-weight:650;
text-align:left;
cursor:pointer;
}
.lg-obtn.primary{background:var(--accent);border:1px solid var(--accent);color:var(--text-on-accent);}
.lg-obtn.primary:hover{background:var(--accent-deep);border-color:var(--accent-deep);}
.lg-obtn.primary .ic{background:rgba(251,250,248,.16);color:var(--text-on-accent);}
.lg-obtn.primary .sub{color:rgba(251,250,248,.72);}
.lg-obtn.primary .arrow{color:rgba(251,250,248,.8);}
.lg-obtn.secondary{background:var(--bg-surface);border:1px solid var(--neutral-150);color:var(--text-strong);}
.lg-obtn.secondary:hover{border-color:var(--neutral-200);}
.lg-obtn.secondary .arrow{color:var(--ink-3);}
.lg-obtn .ic{width:34px;height:34px;border-radius:8px;flex:none;display:flex;align-items:center;justify-content:center;background:var(--bg-surface-2);}
.lg-obtn .txt{display:flex;flex-direction:column;gap:1px;line-height:1.4;}
.lg-obtn .sub{font-size:12px;font-weight:500;color:var(--ink-3);}
.lg-obtn .arrow{margin-left:auto;display:flex;align-items:center;}
.lg-divider{display:flex;align-items:center;gap:var(--sp-4);margin:var(--sp-5) 0;}
.lg-divider .ln{flex:1;height:1px;background:var(--hair);}
.lg-divider .lb{font-size:12px;color:var(--text-muted);font-weight:500;letter-spacing:0.02em;}
/* 역할 선택 */
.lg-rolepick{display:flex;flex-direction:column;gap:var(--sp-2);}
.lg-obtn.secondary{background:var(--bg-surface);border:1px solid var(--border-strong);color:var(--text-strong);}
.lg-obtn.secondary:hover{border-color:var(--accent);}
.lg-obtn:disabled{
cursor:not-allowed;
opacity:1;
background:var(--bg-surface-2);
border-color:var(--border-subtle);
color:var(--text-muted);
}
.lg-obtn:disabled:hover{
background:var(--bg-surface-2);
border-color:var(--border-subtle);
}
.lg-obtn:disabled .ic{
background:var(--bg-surface);
color:var(--text-muted);
}
.lg-obtn:disabled .sub{
color:var(--text-muted);
}
.lg-obtn .ic{
width:36px;
height:36px;
display:grid;
place-items:center;
border-radius:8px;
background:rgba(255,255,255,.16);
}
.lg-obtn.secondary .ic{background:var(--bg-surface-2);}
.lg-obtn .txt{min-width:0;display:flex;flex-direction:column;gap:1px;}
.lg-obtn .sub{font-size:12px;font-weight:550;color:var(--text-muted);}
.lg-obtn.primary .sub{color:rgba(251,250,248,.74);}
.lg-config{
display:flex;
align-items:flex-start;
gap:10px;
margin-top:var(--sp-3);
padding:10px 12px;
border-radius:var(--radius);
background:var(--warn-tint);
color:var(--warn-text);
font-size:12.5px;
line-height:1.5;
}
.lg-dev{
margin-top:var(--sp-6);
padding-top:var(--sp-5);
border-top:1px solid var(--hair);
}
.lg-devhead{
display:flex;
align-items:baseline;
justify-content:space-between;
gap:var(--sp-3);
color:var(--text-strong);
font-size:13px;
font-weight:700;
}
.lg-devhead small{color:var(--text-muted);font-weight:600;}
.lg-rolepick{display:grid;grid-template-columns:1fr;gap:var(--sp-2);margin-top:var(--sp-3);}
.lg-roleopt{
display:flex;align-items:center;gap:12px;width:100%;text-align:left;
padding:11px 14px;border-radius:var(--radius);cursor:pointer;
background:var(--bg-surface);border:1px solid var(--neutral-150);color:var(--text-strong);
transition:border-color .15s var(--ease-out),background .15s var(--ease-out);
min-height:50px;
display:grid;
grid-template-columns:10px minmax(0,1fr) 16px;
align-items:center;
gap:10px;
padding:9px 11px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
color:var(--text-strong);
text-align:left;
cursor:pointer;
}
.lg-roleopt:hover{border-color:var(--neutral-200);}
.lg-roleopt.is-sel{border-color:var(--accent);background:var(--accent-tint);}
.lg-roleopt .d{width:8px;height:8px;border-radius:50%;flex:none;}
.lg-roleopt .d{width:8px;height:8px;border-radius:50%;}
.lg-roleopt .d.learner{background:var(--accent-bright);}
.lg-roleopt .d.instructor{background:#5478C4;}
.lg-roleopt .d.admin{background:#7D818E;}
.lg-roleopt .rt{display:flex;flex-direction:column;gap:1px;}
.lg-roleopt .rt b{font-size:14px;font-weight:600;}
.lg-roleopt .rt .rd{font-size:12px;color:var(--text-muted);}
.lg-roleopt .ck{margin-left:auto;color:var(--accent-deep);display:flex;align-items:center;}
/* 고지 */
.lg-notice{margin-top:var(--sp-6);background:var(--info-tint);border-radius:var(--radius);padding:14px 16px;display:flex;gap:11px;align-items:flex-start;}
.lg-notice .ic{flex:none;margin-top:1px;color:var(--info-solid);}
.lg-notice .tx{font-size:12.5px;line-height:1.6;color:var(--info-text);}
.lg-notice .tx b{font-weight:600;}
.lg-help{margin-top:var(--sp-6);font-size:13px;color:var(--text-muted);line-height:1.7;}
.lg-help a{color:var(--accent-deep);text-decoration:none;font-weight:600;}
.lg-help a:hover{text-decoration:underline;}
/* 반응형 */
@media (max-width:920px){
.lg-shell{grid-template-columns:1fr;}
.lg-brand{padding:var(--sp-8) var(--sp-7) var(--sp-7);gap:var(--sp-7);}
.lg-lede h1{font-size:30px;}
.lg-enter{padding:var(--sp-8) var(--sp-5);}
.lg-topbar{padding:0 var(--sp-5);}
.lg-roleopt .d.teacher{background:#5478c4;}
.lg-roleopt .d.admin{background:#7d818e;}
.lg-roleopt b{display:block;font-size:13px;}
.lg-roleopt small{display:block;margin-top:1px;color:var(--text-muted);font-size:12px;}
.lg-devbtn{
width:100%;
min-height:46px;
margin-top:var(--sp-3);
border:1px solid var(--accent);
border-radius:var(--radius);
background:var(--accent-tint);
color:var(--accent-deep);
font-family:var(--font-sans);
font-size:14px;
font-weight:750;
cursor:pointer;
}
@media (max-width:520px){
.lg-lede h1{font-size:26px;}
.lg-roles{flex-wrap:wrap;gap:var(--sp-3) var(--sp-4);}
.lg-roles .sep{display:none;}
.lg-devbtn:disabled{opacity:.62;cursor:wait;}
.lg-error{
margin:var(--sp-3) 0 0;
color:var(--crit-text);
background:var(--crit-tint);
border-radius:var(--radius);
padding:10px 12px;
font-size:13px;
line-height:1.5;
}
@media (prefers-reduced-motion: reduce){
.lg-brand::before{animation:none;}
.lg-note{
margin:var(--sp-5) 0 0;
color:var(--text-muted);
font-size:12.5px;
line-height:1.6;
}
@media (max-width:880px){
.lg-root{grid-template-columns:1fr;}
.lg-brand{padding:var(--sp-6) var(--sp-5);gap:var(--sp-6);}
.lg-copy h1{font-size:36px;}
.lg-enter{padding:var(--sp-5);}
.lg-panel{max-width:560px;}
}
@media (max-width:480px){
.lg-brand{padding:var(--sp-5);}
.lg-copy h1{font-size:30px;}
.lg-copy p{font-size:15px;}
.lg-enter{padding:var(--sp-4);}
.lg-panel{padding:var(--sp-5);}
}
`;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,4 @@
/* =====================================================================
SessionReview (/learn/session/:sessionId/review).
DESIGN_CONCEPT §5.7: 타임라인 + + + .
: 점수// (24px/600) + + + (3).
ref(session-review-external-ref.html) // ,
Vignette (·) .
mock(./session-review/mock.ts) digest .
===================================================================== */
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { AppShell } from "../components/shell/AppShell";
import {
@ -19,24 +10,27 @@ import {
Kicker,
ProgressBar,
} from "../components/ui";
import { ValenceChart } from "./session-review/ValenceChart";
import {
MOCK_REVIEW,
type SupervisorNote,
type Technique,
type Turn,
} from "./session-review/mock";
sessionApi,
type ReviewNote,
type ReviewPoint,
type ReviewTechnique,
type ReviewTurn,
type SessionReviewResponse,
} from "../lib/api";
import { ValenceChart } from "./session-review/ValenceChart";
import "./session-review/session-review.css";
/** 한 줄 요약의 <hl>…</hl> 마커를 accent 강조 span 으로 렌더. */
type LoadState = "loading" | "ready" | "error";
function renderSummary(summary: string) {
const parts = summary.split(/(<hl>.*?<\/hl>)/g);
return parts.map((part, i) => {
const m = part.match(/^<hl>(.*?)<\/hl>$/);
if (m) {
const match = part.match(/^<hl>(.*?)<\/hl>$/);
if (match) {
return (
<span key={i} className="sr-hl">
{m[1]}
{match[1]}
</span>
);
}
@ -44,8 +38,16 @@ function renderSummary(summary: string) {
});
}
/** 기법 라벨 칩. */
function TechniqueChip({ tech }: { tech: Technique }) {
function EmptyBlock({ title, desc }: { title: string; desc: string }) {
return (
<div className="vg-empty sr-empty">
<div className="vg-empty__title">{title}</div>
<div className="vg-empty__desc">{desc}</div>
</div>
);
}
function TechniqueChip({ tech }: { tech: ReviewTechnique }) {
return (
<span className={`sr-technique sr-technique--${tech.kind}`}>
<span className="sr-technique__dot" aria-hidden="true" />
@ -54,11 +56,10 @@ function TechniqueChip({ tech }: { tech: Technique }) {
);
}
/** 인라인 슈퍼바이저 노트 — 말풍선 금지, 발화 아래 들여쓴 tint 콜아웃. */
function SupervisorCallout({ note }: { note: SupervisorNote }) {
function SupervisorCallout({ note }: { note: ReviewNote }) {
const toneCls = note.tone === "good" ? "sr-note--ai" : "sr-note--warn";
const iconName = note.tone === "good" ? "check" : "info";
const tag = note.author === "ai" ? "AI" : "교수자";
const tag = note.author === "ai" ? "AI" : note.author === "transcript" ? "기록" : "교수자";
return (
<div className={`sr-note ${toneCls}`}>
<div className="sr-note__head">
@ -79,31 +80,93 @@ function SupervisorCallout({ note }: { note: SupervisorNote }) {
);
}
function JumpablePoint({
point,
turns,
onJump,
}: {
point: ReviewPoint;
turns: ReviewTurn[];
onJump: (id: string) => void;
}) {
const targetTs = point.jumpTo ? turns.find((turn) => turn.id === point.jumpTo)?.ts : null;
return (
<div className="sr-point">
<span className="sr-point__mk" aria-hidden="true" />
<div>
<div className="sr-point__h">{point.title}</div>
<div className="sr-point__d">
{point.body}
{point.jumpTo && targetTs ? (
<button
type="button"
className="sr-point__at tabular"
onClick={() => onJump(point.jumpTo!)}
>
{targetTs}
</button>
) : null}
</div>
</div>
</div>
);
}
export default function SessionReview() {
const { sessionId } = useParams<{ sessionId: string }>();
const data = MOCK_REVIEW;
// 인터랙션: 학습자 발화만 보기 토글 + 타임라인 마커 클릭 → 발화 점프(강조)
const [data, setData] = useState<SessionReviewResponse | null>(null);
const [loadState, setLoadState] = useState<LoadState>("loading");
const [error, setError] = useState<string | null>(null);
const [learnerOnly, setLearnerOnly] = useState(false);
const [activeTurn, setActiveTurn] = useState<string | null>(null);
const turnRefs = useRef<Record<string, HTMLDivElement | null>>({});
const visibleTurns = useMemo<Turn[]>(
() => (learnerOnly ? data.turns.filter((t) => t.speaker === "learner") : data.turns),
[learnerOnly, data.turns],
);
useEffect(() => {
let alive = true;
if (!sessionId) {
setError("세션 ID가 없습니다.");
setLoadState("error");
return () => {
alive = false;
};
}
(async () => {
setLoadState("loading");
setError(null);
try {
const next = await sessionApi.review(sessionId);
if (!alive) return;
setData(next);
setLoadState("ready");
} catch (err) {
if (!alive) return;
setError(err instanceof Error ? err.message : "회기 리뷰를 불러오지 못했습니다.");
setLoadState("error");
}
})();
return () => {
alive = false;
};
}, [sessionId]);
const visibleTurns = useMemo<ReviewTurn[]>(() => {
const turns = data?.turns ?? [];
return learnerOnly ? turns.filter((turn) => turn.speaker === "learner") : turns;
}, [learnerOnly, data?.turns]);
const phaseTotal = useMemo(
() => data.phases.reduce((acc, p) => acc + p.weight, 0),
[data.phases],
() => Math.max(1, (data?.phases ?? []).reduce((acc, phase) => acc + phase.weight, 0)),
[data?.phases],
);
function jumpToTurn(id: string) {
if (!data) return;
setActiveTurn(id);
if (learnerOnly && data.turns.find((t) => t.id === id)?.speaker === "client") {
if (learnerOnly && data.turns.find((turn) => turn.id === id)?.speaker === "client") {
setLearnerOnly(false);
}
// 다음 프레임에 스크롤(필터 해제 반영 후)
requestAnimationFrame(() => {
turnRefs.current[id]?.scrollIntoView({
behavior: "smooth",
@ -112,10 +175,46 @@ export default function SessionReview() {
});
}
if (loadState === "loading") {
return (
<AppShell contextLabel="회기 리뷰">
<div className="sr-root">
<Card>
<Kicker> </Kicker>
<EmptyBlock title="리뷰를 불러오는 중" desc="회기 축어록을 확인하고 있습니다." />
</Card>
</div>
</AppShell>
);
}
if (loadState === "error" || !data) {
return (
<AppShell contextLabel="회기 리뷰">
<div className="sr-root">
<Card>
<Kicker> </Kicker>
<EmptyBlock
title="리뷰를 표시할 수 없습니다"
desc={error ?? "세션을 찾을 수 없거나 접근 권한이 없습니다."}
/>
</Card>
</div>
</AppShell>
);
}
const hasValence = data.clientValence.length > 0 || data.counselorBaseline.length > 0;
const canOpenAudio = Boolean(data.audioUrl);
const canExportPdf = Boolean(data.pdfExportUrl);
const hasTranscript = data.turns.length > 0;
const reviewReadiness = hasTranscript
? `${data.turns.length}개 발화 기반`
: "축어록 저장 후 생성";
return (
<AppShell contextLabel="회기 리뷰">
<div className="sr-root">
{/* ── (1) 세션 헤더 ── */}
<div className={`sr-root ${hasTranscript ? "" : "sr-root--empty"}`}>
<Card className="sr-head">
<div className="sr-head__id">
<span className="sr-avatar" aria-hidden="true">
@ -134,12 +233,11 @@ export default function SessionReview() {
</div>
</div>
{/* 우측 스탯 — 점수 박스 금지, '성장 신호'로 톤다운 */}
<div className="sr-head__stats">
<div className="sr-stat">
<span className="sr-stat__lab">SESSION</span>
<span className="sr-stat__val sr-stat__val--accent">
<Dot tone="accent" size={7} />
<Dot tone={data.reviewReady ? "accent" : "warn"} size={7} />
{data.sessionSignal}
</span>
</div>
@ -156,75 +254,106 @@ export default function SessionReview() {
</Card>
<div className="sr-cols">
{/* ── LEFT: 요약 + 차트 + 흐름 + 트랜스크립트 ── */}
<div className="sr-left">
<Card>
<Card className="sr-overview">
<Kicker> </Kicker>
<p className="sr-summary" style={{ marginTop: "var(--sp-3)" }}>
<p className="sr-summary">
{renderSummary(data.summary)}
</p>
<div
style={{
display: "flex",
gap: "var(--sp-3)",
marginTop: "var(--sp-5)",
flexWrap: "wrap",
}}
>
<Button variant="secondary" size="sm" leading={<Icon name="play" size={14} />}>
<div className="sr-readiness" aria-label="리뷰 생성 상태">
<span>
<b>{reviewReadiness}</b>
<small> </small>
</span>
<span>
<b>{data.reviewReady ? "준비됨" : "대기"}</b>
<small> </small>
</span>
<span>
<b>{data.reachedPhase}</b>
<small> </small>
</span>
</div>
<div className="sr-actions">
<Button
variant="secondary"
size="sm"
leading={<Icon name="play" size={14} />}
disabled={!canOpenAudio}
onClick={() => {
if (data.audioUrl) window.open(data.audioUrl, "_blank", "noopener");
}}
>
</Button>
<Button variant="ghost" size="sm" leading={<Icon name="review" size={14} />}>
<Button
variant="ghost"
size="sm"
leading={<Icon name="review" size={14} />}
disabled={!canExportPdf}
onClick={() => {
if (data.pdfExportUrl) window.open(data.pdfExportUrl, "_blank", "noopener");
}}
>
PDF
</Button>
</div>
</Card>
{/* 감정 밸런스 타임라인 */}
<Card>
<Card className="sr-card sr-card--chart">
<Kicker> </Kicker>
<div style={{ marginTop: "var(--sp-4)" }}>
<ValenceChart
client={data.clientValence}
baseline={data.counselorBaseline}
xLabels={data.valenceAxis}
/>
{hasValence ? (
<ValenceChart
client={data.clientValence}
baseline={data.counselorBaseline}
xLabels={data.valenceAxis}
/>
) : (
<EmptyBlock
title="감정 타임라인 대기"
desc="평가 AI가 생성한 감정 추적 데이터가 아직 없습니다."
/>
)}
</div>
</Card>
{/* 회기 흐름 단계 막대 */}
<Card>
<Card className="sr-card sr-card--flow">
<Kicker> </Kicker>
<div className="sr-phasebar" style={{ marginTop: "var(--sp-4)" }}>
<div className="sr-phasebar__track">
{data.phases.map((p) => (
<div
key={p.key}
className="sr-phase"
style={{
flex: p.weight,
// 단계별 accent-tint 농담(좌→우 옅어짐). 색 박스 강조 아님, 흐름 신호.
background: `color-mix(in srgb, var(--accent-tint) ${
100 - data.phases.findIndex((x) => x.key === p.key) * 22
}%, var(--surface))`,
}}
title={`${p.label} · ${Math.round((p.weight / phaseTotal) * 100)}%`}
>
<span className="sr-phase__dot" aria-hidden="true" />
{p.label}
{data.phases.length > 0 ? (
<>
<div className="sr-phasebar__track">
{data.phases.map((phase, index) => (
<div
key={`${phase.key}-${index}`}
className="sr-phase"
style={{
flex: phase.weight,
background: `color-mix(in srgb, var(--accent-tint) ${
100 - index * 22
}%, var(--surface))`,
}}
title={`${phase.label} · ${Math.round((phase.weight / phaseTotal) * 100)}%`}
>
<span className="sr-phase__dot" aria-hidden="true" />
{phase.label}
</div>
))}
</div>
))}
</div>
<div className="sr-phasebar__axis tabular">
{data.phaseAxis.map((t, i) => (
<span key={i}>{t}</span>
))}
</div>
<div className="sr-phasebar__axis tabular">
{data.phaseAxis.map((label, index) => (
<span key={`${label}-${index}`}>{label}</span>
))}
</div>
</>
) : (
<EmptyBlock title="흐름 데이터 없음" desc="저장된 단계 변화가 아직 없습니다." />
)}
</div>
</Card>
{/* 세션 트랜스크립트 */}
<Card>
<Card className="sr-card sr-card--transcript">
<div className="sr-tx__head">
<Kicker> </Kicker>
<div className="sr-tx__filters">
@ -233,6 +362,7 @@ export default function SessionReview() {
className="sr-chip-toggle"
aria-pressed={!learnerOnly}
onClick={() => setLearnerOnly(false)}
disabled={data.turns.length === 0}
>
</button>
@ -241,6 +371,7 @@ export default function SessionReview() {
className="sr-chip-toggle"
aria-pressed={learnerOnly}
onClick={() => setLearnerOnly(true)}
disabled={data.turns.length === 0}
>
</button>
@ -248,159 +379,170 @@ export default function SessionReview() {
</div>
<div className="sr-turns">
{visibleTurns.map((turn, idx) => {
const isLast = idx === visibleTurns.length - 1;
return (
<div
key={turn.id}
ref={(el) => {
turnRefs.current[turn.id] = el;
}}
className={
"sr-turn" + (activeTurn === turn.id ? " sr-turn--active" : "")
}
>
<div className="sr-turn__rail">
<span className="sr-turn__ts">{turn.ts}</span>
<span
className={`sr-turn__node sr-turn__node--${turn.speaker}`}
aria-hidden="true"
/>
{!isLast ? <span className="sr-turn__stem" aria-hidden="true" /> : null}
</div>
<div className="sr-turn__body">
<div className={`sr-turn__who sr-turn__who--${turn.speaker}`}>
{turn.who}
{turn.techniques?.map((tech, i) => (
<TechniqueChip key={i} tech={tech} />
))}
{visibleTurns.length > 0 ? (
visibleTurns.map((turn, idx) => {
const isLast = idx === visibleTurns.length - 1;
return (
<div
key={turn.id}
ref={(el) => {
turnRefs.current[turn.id] = el;
}}
className={
"sr-turn" + (activeTurn === turn.id ? " sr-turn--active" : "")
}
>
<div className="sr-turn__rail">
<span className="sr-turn__ts">{turn.ts}</span>
<span
className={`sr-turn__node sr-turn__node--${turn.speaker}`}
aria-hidden="true"
/>
{!isLast ? <span className="sr-turn__stem" aria-hidden="true" /> : null}
</div>
<div className="sr-turn__body">
<div className={`sr-turn__who sr-turn__who--${turn.speaker}`}>
{turn.who}
{turn.techniques.map((tech, i) => (
<TechniqueChip key={`${tech.label}-${i}`} tech={tech} />
))}
</div>
<p
className={
"sr-turn__said" +
(turn.speaker === "client" ? " sr-turn__said--client" : "")
}
>
{turn.text}
</p>
{turn.note ? <SupervisorCallout note={turn.note} /> : null}
</div>
<p
className={
"sr-turn__said" +
(turn.speaker === "client" ? " sr-turn__said--client" : "")
}
>
{turn.text}
</p>
{turn.note ? <SupervisorCallout note={turn.note} /> : null}
</div>
</div>
);
})}
);
})
) : (
<EmptyBlock
title="축어록 없음"
desc="아직 이 세션에 저장된 실제 발화가 없습니다."
/>
)}
</div>
</Card>
</div>
{/* ── RIGHT: 스킬 루브릭 + 잘한 순간 + 개선점 + AI 내담자 피드백 ── */}
<div className="sr-right">
{/* 스킬 루브릭 (가로 바, 빈도+적절성 — 점수 아님) */}
<Card>
<Card className="sr-card sr-card--side">
<Kicker> </Kicker>
<div className="sr-rubric" style={{ marginTop: "var(--sp-4)" }}>
{data.rubric.map((row) => (
<div key={row.name} className="sr-rubric__row">
<div className="sr-rubric__top">
<span className="sr-rubric__name">{row.name}</span>
<span className={`sr-rubric__qual sr-rubric__qual--${row.quality === "good" ? "good" : "watch"}`}>
<span className="sr-technique__dot" aria-hidden="true" />
{row.quality === "good" ? "적절" : "살펴볼 점"}
</span>
</div>
<ProgressBar
value={row.ratio}
tone={row.quality === "good" ? "accent" : "warn"}
slim
label={`${row.name} 사용 빈도`}
/>
<div className="sr-rubric__cluster">
{row.cluster} · {row.freq}
</div>
</div>
))}
</div>
</Card>
{/* 잘한 순간 */}
<Card>
<Kicker> </Kicker>
<div className="sr-points sr-points--good" style={{ marginTop: "var(--sp-4)" }}>
{data.goodMoments.map((pt, i) => (
<div key={i} className="sr-point">
<span className="sr-point__mk" aria-hidden="true" />
<div>
<div className="sr-point__h">{pt.title}</div>
<div className="sr-point__d">
{pt.body}
{pt.jumpTo ? (
<button
type="button"
className="sr-point__at tabular"
onClick={() => jumpToTurn(pt.jumpTo!)}
>
{data.turns.find((t) => t.id === pt.jumpTo)?.ts ?? ""}
</button>
) : null}
{data.rubric.length > 0 ? (
data.rubric.map((row) => (
<div key={row.name} className="sr-rubric__row">
<div className="sr-rubric__top">
<span className="sr-rubric__name">{row.name}</span>
<span
className={`sr-rubric__qual sr-rubric__qual--${
row.quality === "good" ? "good" : "watch"
}`}
>
<span className="sr-technique__dot" aria-hidden="true" />
{row.quality === "good" ? "적절" : "살펴보기"}
</span>
</div>
<ProgressBar
value={row.ratio}
tone={row.quality === "good" ? "accent" : "warn"}
slim
label={`${row.name} 사용 빈도`}
/>
<div className="sr-rubric__cluster">
{row.cluster} · {row.freq}
</div>
</div>
</div>
))}
))
) : (
<EmptyBlock
title="평가 대기"
desc="기법 분포는 평가 AI 또는 교수자 리뷰가 생성된 뒤 표시됩니다."
/>
)}
</div>
</Card>
{/* 다음 회기 개선점 (최대 3개) */}
<Card>
<Card className="sr-card sr-card--side">
<Kicker> </Kicker>
<div className="sr-points sr-points--good" style={{ marginTop: "var(--sp-4)" }}>
{data.goodMoments.length > 0 ? (
data.goodMoments.map((point, i) => (
<JumpablePoint
key={`${point.title}-${i}`}
point={point}
turns={data.turns}
onJump={jumpToTurn}
/>
))
) : (
<EmptyBlock
title="아직 코멘트 없음"
desc="교수자 또는 평가 AI가 근거가 있는 강점을 생성하면 이곳에 표시됩니다."
/>
)}
</div>
</Card>
<Card className="sr-card sr-card--side">
<Kicker> </Kicker>
<div className="sr-points sr-points--grow" style={{ marginTop: "var(--sp-4)" }}>
{data.growthPoints.slice(0, 3).map((pt, i) => (
<div key={i} className="sr-point">
<span className="sr-point__mk" aria-hidden="true" />
<div>
<div className="sr-point__h">{pt.title}</div>
<div className="sr-point__d">
{pt.body}
{pt.jumpTo ? (
<button
type="button"
className="sr-point__at tabular"
onClick={() => jumpToTurn(pt.jumpTo!)}
>
{data.turns.find((t) => t.id === pt.jumpTo)?.ts ?? ""}
</button>
) : null}
</div>
</div>
</div>
))}
</div>
<div className="sr-nextline">
<div className="sr-nextline__lab"> </div>
<div className="sr-nextline__q">{data.nextLine}</div>
{data.growthPoints.length > 0 ? (
data.growthPoints.slice(0, 3).map((point, i) => (
<JumpablePoint
key={`${point.title}-${i}`}
point={point}
turns={data.turns}
onJump={jumpToTurn}
/>
))
) : (
<EmptyBlock
title="개선점 대기"
desc="실제 평가 결과가 없는 동안에는 개선점을 임의로 제시하지 않습니다."
/>
)}
</div>
{data.nextLine ? (
<div className="sr-nextline">
<div className="sr-nextline__lab"> </div>
<div className="sr-nextline__q">{data.nextLine}</div>
</div>
) : null}
</Card>
{/* AI 내담자 피드백 (다크 카드, italic 1인칭) */}
<div className="sr-feedback">
<div className="sr-feedback__kicker">
<span className="sr-technique__dot" aria-hidden="true" />
</div>
<p className="sr-feedback__quote">&ldquo;{data.clientFeedback}&rdquo;</p>
<div className="sr-feedback__src">
<Icon name="info" size={13} strokeWidth={2} />
AI
</div>
{data.clientFeedback ? (
<>
<p className="sr-feedback__quote">&ldquo;{data.clientFeedback}&rdquo;</p>
<div className="sr-feedback__src">
<Icon name="info" size={13} strokeWidth={2} />
AI
</div>
</>
) : (
<>
<p className="sr-feedback__quote"> .</p>
<div className="sr-feedback__src">
<Icon name="info" size={13} strokeWidth={2} />
</div>
</>
)}
</div>
{sessionId ? (
<p
style={{
fontSize: "var(--fs-xs)",
color: "var(--text-muted)",
fontFamily: "var(--font-num)",
}}
>
<p className="sr-session-id">
ID · <span className="tabular">{sessionId}</span>
</p>
) : null}

File diff suppressed because it is too large Load diff

View file

@ -1,105 +0,0 @@
/* =====================================================================
Admin inline SVG .
Icon(ui/Icon.tsx) .
( Icon .)
철칙: 이모지 . stroke=currentColor, fill=none.
===================================================================== */
import type { ReactNode, SVGProps } from "react";
type LocalIconName =
| "search"
| "download"
| "database"
| "persona"
| "megaphone"
| "shield-check"
| "sliders";
interface AdminIconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
name: LocalIconName;
size?: number;
strokeWidth?: number;
}
const PATHS: Record<LocalIconName, ReactNode> = {
search: (
<>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</>
),
download: (
<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</>
),
database: (
<>
<ellipse cx="12" cy="5" rx="8" ry="3" />
<path d="M4 5v14c0 1.66 3.58 3 8 3s8-1.34 8-3V5" />
<path d="M4 12c0 1.66 3.58 3 8 3s8-1.34 8-3" />
</>
),
persona: (
<>
<circle cx="12" cy="8" r="4" />
<path d="M4 21c0-4 3.6-6 8-6s8 2 8 6" />
</>
),
megaphone: (
<>
<path d="M3 11l18-5v12L3 14v-3z" />
<path d="M11.6 16.8a3 3 0 1 1-5.8-1.6" />
</>
),
"shield-check": (
<>
<path d="M12 2l8 3v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V5l8-3z" />
<path d="M9 12l2 2 4-4" />
</>
),
sliders: (
<>
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</>
),
};
/** Admin 로컬 아이콘 — stroke 기반, currentColor. */
export function AdminIcon({
name,
size = 18,
strokeWidth = 1.8,
...rest
}: AdminIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
{...rest}
>
{PATHS[name]}
</svg>
);
}
export type { LocalIconName };

View file

@ -1,203 +0,0 @@
/* =====================================================================
audit log + + (geo) + .
§6.6: 시간순 , ··. (). warn .
(Admin) state / .
===================================================================== */
import { Badge, Dot } from "../../components/ui";
import { AdminIcon } from "./AdminIcons";
import {
ACTION_META,
scopeTone,
type AuditAction,
type AuditEntry,
} from "./data";
export interface AuditFilter {
key: AuditAction | "all";
label: string;
}
export const AUDIT_FILTERS: AuditFilter[] = [
{ key: "all", label: "전체" },
{ key: "view", label: "세션 열람" },
{ key: "audio", label: "오디오 재생" },
{ key: "export", label: "내보내기" },
{ key: "grade", label: "평가 작성" },
{ key: "edit", label: "평가 수정" },
];
export interface AuditTableProps {
/** 현재 페이지에 보일 행 */
rows: AuditEntry[];
/** 활성 필터 */
filter: AuditAction | "all";
onFilter: (key: AuditAction | "all") => void;
/** 검색어 */
query: string;
onQuery: (q: string) => void;
/** 페이지네이션 */
page: number;
pageCount: number;
onPage: (p: number) => void;
/** 전체 매칭 건수(푸터 표기) */
total: number;
/** 현재 페이지 표시 범위 */
rangeFrom: number;
rangeTo: number;
}
function GeoTag({ geo }: { geo: "kr" | "us" }) {
const isKr = geo === "kr";
return (
<span
className="vgad-geo"
title={isKr ? "국내(KR) 추론 — 데이터 주권 준수" : "해외(US) 추론 경유"}
>
<span className={"vgad-geo__dot " + (isKr ? "vgad-geo__dot--kr" : "vgad-geo__dot--us")} />
{isKr ? "KR" : "US"}
</span>
);
}
export function AuditTable(props: AuditTableProps) {
const {
rows,
filter,
onFilter,
query,
onQuery,
page,
pageCount,
onPage,
total,
rangeFrom,
rangeTo,
} = props;
return (
<div className="vgad-tablewrap">
{/* 툴바: 검색 + 행위 필터 칩 */}
<div className="vgad-toolbar">
<label className="vgad-search">
<span className="vgad-search__ic">
<AdminIcon name="search" size={15} strokeWidth={2} />
</span>
<input
value={query}
onChange={(e) => onQuery(e.target.value)}
placeholder="교수자·학습자·세션 ID로 검색"
aria-label="감사 로그 검색"
/>
</label>
{AUDIT_FILTERS.map((f) => (
<button
key={f.key}
type="button"
className={"vgad-chip" + (filter === f.key ? " is-on" : "")}
aria-pressed={filter === f.key}
onClick={() => onFilter(f.key)}
>
{f.label}
</button>
))}
<span className="vgad-toolbar__spacer" />
</div>
<table className="vgad-table">
<thead>
<tr>
<th style={{ width: 96 }}></th>
<th style={{ width: 190 }}></th>
<th style={{ width: 170 }} className="vgad-hide-sm"></th>
<th></th>
<th style={{ width: 74 }} className="vgad-hide-sm"> </th>
<th style={{ width: 120 }}></th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr className="vgad-empty-row">
<td colSpan={6}> .</td>
</tr>
) : (
rows.map((r) => {
const meta = ACTION_META[r.action];
return (
<tr key={r.id}>
<td className="vgad-td-time">{r.time}</td>
<td>
<div className="vgad-actor">
<span className="vgad-actor__av" aria-hidden="true">
{r.actorInitial}
</span>
<span>
<span className="vgad-actor__nm">{r.actorName}</span>
<span className="vgad-actor__sub">{r.actorRole}</span>
</span>
</div>
</td>
<td className="vgad-hide-sm">
<span className="vgad-act">
<Dot tone={meta.tone} size={7} />
{r.actionLabel}
</span>
</td>
<td>
<span className="vgad-target">
{r.target}
<span className="vgad-target__meta">{r.targetMeta}</span>
</span>
</td>
<td className="vgad-hide-sm">
<GeoTag geo={r.geo} />
</td>
<td>
<Badge tone={scopeTone(r.sensitive)}>{r.scope}</Badge>
</td>
</tr>
);
})
)}
</tbody>
</table>
<div className="vgad-tfoot">
<span className="vgad-tfoot__cnt tabular">
{total > 0
? `최근 24시간 ${total}건 중 ${rangeFrom}${rangeTo} 표시 · 모든 열람은 위·변조 불가 로그로 보존됩니다`
: "표시할 감사 기록이 없습니다"}
</span>
<div className="vgad-pg">
<button
type="button"
disabled={page <= 1}
onClick={() => onPage(page - 1)}
aria-label="이전 페이지"
>
</button>
{Array.from({ length: pageCount }, (_, i) => i + 1).map((p) => (
<button
key={p}
type="button"
className={p === page ? "is-on" : ""}
aria-current={p === page ? "page" : undefined}
onClick={() => onPage(p)}
>
{p}
</button>
))}
<button
type="button"
disabled={page >= pageCount}
onClick={() => onPage(page + 1)}
aria-label="다음 페이지"
>
</button>
</div>
</div>
</div>
);
}

View file

@ -1,127 +0,0 @@
/* =====================================================================
(§6.7 ).
/ / .
y축 , (--ink-2), 1 accent .
draw admin.css (prefers-reduced-motion ).
===================================================================== */
import { useMemo, type CSSProperties } from "react";
/** CSS 커스텀 프로퍼티(--vgad-len)를 style 에 안전하게 싣기 위한 확장 타입. */
type CSSVarStyle = CSSProperties & Record<`--${string}`, string | number>;
export interface TrendPoint {
day: string;
value: number;
}
export interface TrendChartProps {
data: TrendPoint[];
/** 접근성 라벨 */
label?: string;
}
const W = 560;
const H = 160;
const PAD_L = 34;
const PAD_R = 16;
const PAD_T = 16;
const PAD_B = 26;
export function TrendChart({ data, label = "세션 추이" }: TrendChartProps) {
const { points, peak, gridYs, niceMax } = useMemo(() => {
const max = Math.max(...data.map((d) => d.value), 1);
// 50 단위로 올림(차트 y 상한 — tabular 라벨용)
const nm = Math.ceil(max / 50) * 50;
const innerW = W - PAD_L - PAD_R;
const innerH = H - PAD_T - PAD_B;
const stepX = data.length > 1 ? innerW / (data.length - 1) : 0;
const pts = data.map((d, i) => ({
...d,
x: PAD_L + stepX * i,
y: PAD_T + innerH * (1 - d.value / nm),
}));
// 피크(최댓값) 1점만 라벨
let peakIdx = 0;
data.forEach((d, i) => {
if (d.value > data[peakIdx].value) peakIdx = i;
});
const ys = [0, 0.5, 1].map((r) => ({
v: Math.round(nm * (1 - r)),
y: PAD_T + innerH * r,
}));
return { points: pts, peak: pts[peakIdx], gridYs: ys, niceMax: nm };
}, [data]);
const path = points
.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
.join(" ");
// 폴리라인 길이 근사 → draw 애니메이션 dasharray 변수
const len = useMemo(() => {
let total = 0;
for (let i = 1; i < points.length; i++) {
const dx = points[i].x - points[i - 1].x;
const dy = points[i].y - points[i - 1].y;
total += Math.hypot(dx, dy);
}
return Math.ceil(total) + 4;
}, [points]);
return (
<svg
className="vgad-trend"
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label={`${label}. 최대 ${niceMax}건, 피크 ${peak.day}요일 ${peak.value}건.`}
style={{ "--vgad-len": len } as CSSVarStyle}
>
{/* y축 점선 격자 + 라벨 (세로 격자 없음) */}
{gridYs.map((g, i) => (
<g key={i}>
<line
className="vgad-trend__grid"
x1={PAD_L}
y1={g.y}
x2={W - PAD_R}
y2={g.y}
/>
<text className="vgad-trend__ylab" x={PAD_L - 8} y={g.y + 3} textAnchor="end">
{g.v}
</text>
</g>
))}
{/* 단일 라인 */}
<path className="vgad-trend__line" d={path} />
{/* 피크 1점만 강조 */}
<circle className="vgad-trend__peak" cx={peak.x} cy={peak.y} r={3.5} />
<text
className="vgad-trend__peaklab"
x={peak.x}
y={peak.y - 10}
textAnchor="middle"
>
{peak.value}
</text>
{/* x축 요일 라벨 */}
{points.map((p, i) => (
<text
key={i}
className="vgad-trend__xlab"
x={p.x}
y={H - 8}
textAnchor="middle"
>
{p.day}
</text>
))}
</svg>
);
}

View file

@ -1,869 +0,0 @@
/* =====================================================================
Admin 페이지 전용 스타일. 토큰(tokens.css) 참조.
네임스페이스 vgad- 공통 ui.css(vg-) 충돌 0.
철칙: border-left 강조선 0 · 이모지 0 · 카드덤프 0 · 순흑/순백 0 ·
radius 절제(8/12) · 강조는 weight+tint+kicker+dot.
===================================================================== */
/* ── 페이지 헤드 (제목 + 우측 동기화 시각) ── */
.vgad-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--sp-5);
margin-bottom: var(--sp-6);
flex-wrap: wrap;
}
.vgad-head__title {
font-size: var(--fs-h1);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-strong);
line-height: 1.25;
margin-top: var(--sp-2);
}
.vgad-head__lead {
font-size: var(--fs-body);
color: var(--text-body);
margin-top: 6px;
max-width: 600px;
}
.vgad-head__when {
font-family: var(--font-num);
font-size: var(--fs-xs);
color: var(--text-muted);
white-space: nowrap;
text-align: right;
}
.vgad-head__when b {
display: block;
color: var(--text-body);
font-weight: 600;
letter-spacing: 0.01em;
}
/* ── 한 줄 상태 요약 (에디토리얼 — 큰 문장 하나) ── */
.vgad-statusline {
display: flex;
align-items: center;
gap: 14px;
padding: var(--sp-5) var(--sp-6);
background: var(--bg-surface);
border: 1px solid var(--hair);
border-radius: var(--radius-lg);
}
.vgad-statusline__pulse {
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--pos-solid);
flex: none;
box-shadow: 0 0 0 0 rgba(59, 140, 94, 0.4);
animation: vgad-pulse var(--dur-breathe) var(--ease-in-out) infinite;
}
.vgad-statusline__pulse--warn {
background: var(--warn-solid);
animation: none;
}
.vgad-statusline__pulse--crit {
background: var(--crit-solid);
animation: none;
}
@keyframes vgad-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(59, 140, 94, 0.35);
}
50% {
box-shadow: 0 0 0 8px rgba(59, 140, 94, 0);
}
}
.vgad-statusline__msg {
font-size: var(--fs-lead);
font-weight: 500;
color: var(--text-strong);
line-height: 1.5;
}
.vgad-statusline__msg b {
font-weight: 700;
}
.vgad-statusline__detail {
color: var(--text-body);
font-weight: 400;
}
.vgad-statusline__ts {
margin-left: auto;
font-family: var(--font-num);
font-size: var(--fs-xs);
color: var(--text-muted);
white-space: nowrap;
}
/* ── 운영 KPI (인라인 행 — 카드덤프 아님, 헤어라인 셀 구분) ── */
.vgad-kpis {
display: grid;
grid-template-columns: repeat(5, 1fr);
margin-top: var(--sp-5);
background: var(--bg-surface);
border: 1px solid var(--hair);
border-radius: var(--radius-lg);
overflow: hidden;
}
.vgad-kpi {
padding: var(--sp-5) var(--sp-5);
}
/* 셀 구분 헤어라인 — 강조선이 아니라 컬럼 divider */
.vgad-kpi + .vgad-kpi {
border-left: 1px solid var(--hair);
}
.vgad-kpi__l {
font-size: var(--fs-xs);
color: var(--text-muted);
font-weight: 500;
}
.vgad-kpi__n {
font-size: 32px;
font-weight: 700;
color: var(--text-strong);
letter-spacing: -0.02em;
margin-top: 8px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.vgad-kpi__n--warn {
color: var(--warn-text);
}
.vgad-kpi__n--crit {
color: var(--crit-text);
}
.vgad-kpi__u {
font-size: 15px;
font-weight: 500;
color: var(--text-muted);
margin-left: 4px;
letter-spacing: 0;
}
.vgad-kpi__d {
font-size: var(--fs-xs);
color: var(--text-muted);
margin-top: 9px;
}
.vgad-kpi__d b {
font-weight: 600;
}
.vgad-kpi__d--pos b {
color: var(--pos-text);
}
.vgad-kpi__d--info b {
color: var(--info-text);
}
.vgad-kpi__d--warn b {
color: var(--warn-text);
}
.vgad-kpi__d--crit b {
color: var(--crit-text);
}
/* ── 섹션 ── */
.vgad-section {
margin-top: var(--sp-7);
}
.vgad-sechead {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-4);
margin-bottom: var(--sp-5);
}
.vgad-sechead__title {
font-size: var(--fs-h3);
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text-strong);
}
.vgad-sechead__meta {
font-size: var(--fs-xs);
color: var(--text-muted);
}
/* ── 헬스 라인 ── */
.vgad-health {
background: var(--bg-surface);
border: 1px solid var(--hair);
border-radius: var(--radius-lg);
overflow: hidden;
}
.vgad-hrow {
display: grid;
grid-template-columns: 220px 1fr auto 86px;
align-items: center;
gap: var(--sp-4);
padding: 14px var(--sp-6);
}
.vgad-hrow + .vgad-hrow {
border-top: 1px solid var(--hair);
}
.vgad-hrow__svc {
display: flex;
align-items: center;
gap: 11px;
}
.vgad-hrow__nm {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-strong);
line-height: 1.3;
}
.vgad-hrow__sub {
display: block;
font-size: var(--fs-xs);
color: var(--text-muted);
font-weight: 400;
}
.vgad-hrow__gauge {
height: 5px;
background: var(--paper-2);
border-radius: 3px;
overflow: hidden;
max-width: 300px;
}
.vgad-hrow__fill {
height: 100%;
border-radius: 3px;
background: var(--accent-bright);
transition: width var(--dur-slow) var(--ease-out);
}
.vgad-hrow__fill--warn {
background: var(--warn-solid);
}
.vgad-hrow__fill--crit {
background: var(--crit-solid);
}
.vgad-hrow__num {
font-family: var(--font-num);
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-body);
text-align: right;
white-space: nowrap;
}
.vgad-hrow__stat {
justify-self: end;
}
/* ── 2-컬럼 수집 현황 ── */
.vgad-collect {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: var(--sp-6);
}
.vgad-panel__ph {
font-size: var(--fs-body);
font-weight: 600;
color: var(--text-strong);
margin-bottom: var(--sp-2);
}
.vgad-panel__psub {
font-size: var(--fs-xs);
color: var(--text-muted);
margin-bottom: var(--sp-5);
}
/* 분포 — 가로 게이지 목록 */
.vgad-distrib {
display: flex;
flex-direction: column;
gap: var(--sp-5);
}
.vgad-dist {
display: grid;
grid-template-columns: 130px 1fr 52px;
align-items: center;
gap: var(--sp-4);
}
.vgad-dist__l {
font-size: var(--fs-sm);
color: var(--text-body);
font-weight: 500;
}
.vgad-dist__v {
font-family: var(--font-num);
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-strong);
text-align: right;
}
/* 역할별 사용 — 행 목록 */
.vgad-rolelist {
display: flex;
flex-direction: column;
}
.vgad-rr {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 0;
}
.vgad-rr + .vgad-rr {
border-top: 1px solid var(--paper-2);
}
.vgad-rr__l {
display: flex;
align-items: center;
gap: 11px;
}
.vgad-rr__sq {
width: 9px;
height: 9px;
border-radius: 2px;
flex: none;
}
.vgad-rr__nm {
font-size: var(--fs-sm);
color: var(--text-strong);
font-weight: 500;
}
.vgad-rr__sub {
display: block;
font-size: var(--fs-xs);
color: var(--text-muted);
font-weight: 400;
}
.vgad-rr__v {
font-family: var(--font-num);
font-size: var(--fs-lead);
font-weight: 700;
color: var(--text-strong);
}
.vgad-rr__delta {
font-size: var(--fs-xs);
font-weight: 600;
color: var(--pos-text);
margin-left: 8px;
}
/* ── 사용·비용 미터 목록 ── */
.vgad-usage {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--sp-5) var(--sp-6);
}
.vgad-meter__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-3);
margin-bottom: 8px;
}
.vgad-meter__nm {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-strong);
}
.vgad-meter__sub {
display: block;
font-size: var(--fs-xs);
color: var(--text-muted);
font-weight: 400;
}
.vgad-meter__v {
font-family: var(--font-num);
font-size: var(--fs-body);
font-weight: 700;
color: var(--text-strong);
white-space: nowrap;
}
.vgad-meter__v--warn {
color: var(--warn-text);
}
.vgad-meter__foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 7px;
}
.vgad-meter__budget {
font-family: var(--font-num);
font-size: var(--fs-xs);
color: var(--text-muted);
}
.vgad-meter__pct {
font-family: var(--font-num);
font-size: var(--fs-xs);
font-weight: 600;
color: var(--text-body);
}
.vgad-meter__pct--warn {
color: var(--warn-text);
}
/* ── 세션 추이 라인 차트 (단일 라인, 막대 금지) ── */
.vgad-trend {
display: block;
width: 100%;
height: auto;
overflow: visible;
}
.vgad-trend__grid {
stroke: var(--hair);
stroke-width: 1;
stroke-dasharray: 3 4;
}
.vgad-trend__line {
fill: none;
stroke: var(--ink-2);
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.vgad-trend__peak {
fill: var(--accent);
}
.vgad-trend__peaklab {
font-family: var(--font-num);
font-size: 12px;
font-weight: 700;
fill: var(--accent-deep);
}
.vgad-trend__xlab {
font-family: var(--font-num);
font-size: 11px;
fill: var(--text-muted);
}
.vgad-trend__ylab {
font-family: var(--font-num);
font-size: 10px;
fill: var(--text-muted);
}
@media (prefers-reduced-motion: no-preference) {
.vgad-trend__line {
stroke-dasharray: var(--vgad-len, 1000);
stroke-dashoffset: var(--vgad-len, 1000);
animation: vgad-draw var(--dur-slow) var(--ease-out) forwards;
}
}
@keyframes vgad-draw {
to {
stroke-dashoffset: 0;
}
}
/* ── 교수 활동 감사 + audit log 공통 테이블 ── */
.vgad-tablewrap {
background: var(--bg-surface);
border: 1px solid var(--hair);
border-radius: var(--radius-lg);
overflow: hidden;
}
.vgad-toolbar {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--hair);
flex-wrap: wrap;
}
.vgad-search {
flex: 1;
min-width: 200px;
max-width: 320px;
display: flex;
align-items: center;
gap: 9px;
background: var(--bg-surface-2);
border: 1px solid var(--hair);
border-radius: var(--radius-sm);
padding: 7px 12px;
transition: border-color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
}
.vgad-search:focus-within {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--focus-ring);
}
.vgad-search__ic {
color: var(--text-muted);
flex: none;
display: inline-flex;
}
.vgad-search input {
border: none;
background: none;
outline: none;
font-family: var(--font-sans);
font-size: var(--fs-sm);
color: var(--text-strong);
width: 100%;
}
.vgad-search input::placeholder {
color: var(--text-muted);
}
.vgad-chip {
font-size: var(--fs-xs);
font-weight: 500;
color: var(--text-body);
padding: 7px 13px;
border-radius: var(--radius-sm);
border: 1px solid var(--hair);
background: var(--bg-surface);
cursor: pointer;
white-space: nowrap;
transition: background var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out);
}
.vgad-chip:hover {
background: var(--bg-surface-2);
color: var(--text-strong);
}
.vgad-chip.is-on {
background: var(--accent-tint);
color: var(--accent-deep);
border-color: transparent;
font-weight: 600;
}
.vgad-toolbar__spacer {
flex: 1;
}
.vgad-table {
width: 100%;
border-collapse: collapse;
}
.vgad-table thead th {
font-family: var(--font-num);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
text-align: left;
padding: 11px var(--sp-5);
background: var(--paper-2);
border-bottom: 1px solid var(--hair);
}
.vgad-table thead th.is-num {
text-align: right;
}
.vgad-table tbody td {
padding: 14px var(--sp-5);
border-bottom: 1px solid var(--paper-2);
font-size: var(--fs-sm);
color: var(--text-body);
vertical-align: middle;
}
.vgad-table tbody tr:last-child td {
border-bottom: none;
}
.vgad-table tbody tr {
transition: background var(--dur-fast) var(--ease-out);
}
.vgad-table tbody tr:hover {
background: var(--paper);
}
.vgad-table td.is-num {
text-align: right;
font-family: var(--font-num);
font-variant-numeric: tabular-nums;
}
.vgad-td-time {
font-family: var(--font-num);
font-size: var(--fs-xs);
color: var(--text-muted);
white-space: nowrap;
}
/* 행위자 셀 */
.vgad-actor {
display: flex;
align-items: center;
gap: 10px;
}
.vgad-actor__av {
width: 28px;
height: 28px;
border-radius: 50%;
flex: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
background: var(--accent-tint);
color: var(--accent-deep);
}
.vgad-actor__nm {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-strong);
line-height: 1.3;
}
.vgad-actor__sub {
display: block;
font-size: var(--fs-xs);
color: var(--text-muted);
font-weight: 400;
}
/* 행위 셀 */
.vgad-act {
display: inline-flex;
align-items: center;
gap: 8px;
}
/* 대상 셀 */
.vgad-target {
color: var(--text-strong);
font-weight: 500;
}
.vgad-target__meta {
display: block;
font-size: var(--fs-xs);
color: var(--text-muted);
font-weight: 400;
}
/* 데이터 주권(geo) 표기 */
.vgad-geo {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-num);
font-size: var(--fs-xs);
font-weight: 600;
letter-spacing: 0.04em;
color: var(--text-body);
}
.vgad-geo__dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: none;
}
.vgad-geo__dot--kr {
background: var(--accent-bright);
}
.vgad-geo__dot--us {
background: var(--clay);
}
/* 테이블 푸터(페이지네이션) */
.vgad-tfoot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-4);
padding: 13px var(--sp-5);
border-top: 1px solid var(--hair);
flex-wrap: wrap;
}
.vgad-tfoot__cnt {
font-size: var(--fs-xs);
color: var(--text-muted);
}
.vgad-pg {
display: flex;
gap: 6px;
}
.vgad-pg button {
font-family: var(--font-num);
font-size: var(--fs-xs);
color: var(--text-body);
border: 1px solid var(--hair);
background: var(--bg-surface);
min-width: 30px;
height: 30px;
padding: 0 8px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out);
}
.vgad-pg button:hover:not(:disabled) {
background: var(--bg-surface-2);
color: var(--text-strong);
}
.vgad-pg button.is-on {
background: var(--accent);
color: var(--text-on-accent);
border-color: transparent;
font-weight: 600;
}
.vgad-pg button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* 빈 결과 행 */
.vgad-empty-row td {
padding: var(--sp-6) var(--sp-5);
text-align: center;
color: var(--text-muted);
font-size: var(--fs-sm);
}
/* 교수 감사 — 열람률 미니바 */
.vgad-ratebar {
display: inline-flex;
align-items: center;
gap: 10px;
justify-content: flex-end;
}
.vgad-ratebar__track {
width: 64px;
height: 5px;
background: var(--paper-2);
border-radius: 3px;
overflow: hidden;
}
.vgad-ratebar__fill {
height: 100%;
border-radius: 3px;
background: var(--accent-bright);
}
.vgad-ratebar__fill--warn {
background: var(--warn-solid);
}
.vgad-ratebar__pct {
font-family: var(--font-num);
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-strong);
min-width: 38px;
text-align: right;
}
.vgad-attn {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--warn-text);
font-size: var(--fs-xs);
font-weight: 600;
}
/* ── 관리 진입 (entry 카드, 4개 — 덤프 아님: 명확한 액션 목적지) ── */
.vgad-mgmt {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-4);
}
.vgad-entry {
display: flex;
flex-direction: column;
gap: 11px;
padding: var(--sp-5);
background: var(--bg-surface);
border: 1px solid var(--hair);
border-radius: var(--radius-lg);
text-decoration: none;
color: inherit;
text-align: left;
cursor: pointer;
font-family: inherit;
transition: border-color var(--dur-base) var(--ease-out),
transform var(--dur-base) var(--ease-out);
}
.vgad-entry:hover {
border-color: var(--accent-bright);
transform: translateY(-1px);
text-decoration: none;
}
.vgad-entry__head {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.vgad-entry__ico {
width: 34px;
height: 34px;
border-radius: var(--radius);
background: var(--accent-tint);
color: var(--accent-deep);
display: flex;
align-items: center;
justify-content: center;
flex: none;
}
.vgad-entry__t {
font-size: var(--fs-sm);
font-weight: 600;
color: var(--text-strong);
}
.vgad-entry__d {
font-size: var(--fs-xs);
color: var(--text-muted);
line-height: 1.55;
}
.vgad-entry__go {
margin-top: auto;
font-size: var(--fs-xs);
font-weight: 600;
color: var(--accent);
display: flex;
align-items: center;
gap: 5px;
padding-top: 4px;
}
/* ── 페이지 푸터 (윤리/버전) ── */
.vgad-foot {
margin-top: var(--sp-7);
padding-top: var(--sp-5);
border-top: 1px solid var(--hair);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-4);
font-size: var(--fs-xs);
color: var(--text-muted);
flex-wrap: wrap;
}
/* ── 반응형 ── */
@media (max-width: 1080px) {
.vgad-kpis {
grid-template-columns: repeat(2, 1fr);
}
.vgad-kpi:nth-child(odd) {
border-left: none;
}
.vgad-kpi:nth-child(n + 3) {
border-top: 1px solid var(--hair);
}
.vgad-kpi:nth-child(even) {
border-left: 1px solid var(--hair);
}
.vgad-collect {
grid-template-columns: 1fr;
}
.vgad-usage {
grid-template-columns: 1fr;
}
.vgad-mgmt {
grid-template-columns: repeat(2, 1fr);
}
.vgad-hrow {
grid-template-columns: 180px 1fr auto;
}
.vgad-hrow__stat {
display: none;
}
}
@media (max-width: 720px) {
.vgad-hrow {
grid-template-columns: 1fr auto;
gap: var(--sp-3);
}
.vgad-hrow__gauge {
display: none;
}
.vgad-mgmt {
grid-template-columns: 1fr;
}
.vgad-table td.vgad-hide-sm,
.vgad-table th.vgad-hide-sm {
display: none;
}
}

View file

@ -1,357 +0,0 @@
/* =====================================================================
Admin mock + .
[] mock()
. api.ts .
§6.6: 운영 KPI · · /AI + · · audit log.
===================================================================== */
import type { BadgeTone, DotTone } from "../../components/ui";
/* ── 시스템 헬스 상태 ── */
export type HealthStatus = "ok" | "warn" | "crit";
export interface ServiceHealth {
/** 서비스 명 */
name: string;
/** 보조 설명(추론 대상 등) */
sub: string;
status: HealthStatus;
/** 부하/사용 게이지 0~1 */
load: number;
/** 우측 수치 라벨 (mono) */
metric: string;
/** 상태 배지 라벨 */
statusLabel: string;
}
/* ── 운영 KPI ── */
export interface Kpi {
label: string;
/** 본문 숫자 (이미 포맷된 문자열, tabular) */
value: string;
/** 숫자 뒤 단위 */
unit?: string;
/** 보조 설명(변화량 등) */
note?: string;
/** 보조 설명 톤 — 정상은 무채(undefined) */
noteTone?: "pos" | "info" | "warn" | "crit";
/** KPI 자체 강조색 — 정상은 무채(undefined). "조용한 게 정상" */
tone?: "warn" | "crit";
}
/* ── 사용량·비용 ── */
export interface UsageMeter {
label: string;
sub: string;
/** 표시 수치 (tabular) */
value: string;
/** 예산 대비 비율 0~1 (게이지) */
ratio: number;
/** 예산 라벨 (게이지 우측) */
budget: string;
}
/* ── 시나리오 분포 ── */
export interface DistRow {
label: string;
count: number;
/** 게이지 비율 0~1 */
ratio: number;
/** 게이지 톤 */
tone?: "accent" | "clay" | "warn";
}
/* ── 역할별 사용 ── */
export interface RoleRow {
label: string;
sub: string;
/** 색 점(role accent hex — 토큰 외 브랜드성 식별색) */
color: string;
active: number;
delta?: number;
}
/* ── 교수 활동 감사(요약 테이블) ── */
export interface ProfessorAudit {
name: string;
initial: string;
/** 담당 코호트 */
cohort: string;
/** 피드백 열람률 0~1 */
reviewRate: number;
/** 발행 시나리오 수 */
published: number;
/** 마지막 활동(상대 시각) */
lastActive: string;
/** 점검 필요 여부 — true 면 "점검" 신호 */
needsAttention?: boolean;
}
/* ── audit log(행위 로그) ── */
export type AuditAction = "view" | "audio" | "export" | "grade" | "edit";
export interface AuditEntry {
id: string;
time: string; // HH:MM:SS
actorName: string;
actorInitial: string;
actorRole: string;
action: AuditAction;
actionLabel: string;
target: string;
targetMeta: string;
/** 데이터 주권 — 추론이 일어난 지역(kr/us) */
geo: "kr" | "us";
/** 접근 범위 라벨 */
scope: string;
/** 민감 접근(학습자 식별/원본 등) */
sensitive?: boolean;
}
/* === action 메타: dot 톤 + 한국어 라벨 매핑 === */
export const ACTION_META: Record<AuditAction, { tone: DotTone; label: string }> = {
view: { tone: "info", label: "열람" },
audio: { tone: "accent", label: "오디오" },
export: { tone: "warn", label: "내보내기" },
grade: { tone: "pos", label: "평가 작성" },
edit: { tone: "warn", label: "평가 수정" },
};
/* === scope 배지 톤 === */
export function scopeTone(sensitive?: boolean): BadgeTone {
return sensitive ? "warn" : "neutral";
}
/* =====================================================================
mock ( /)
===================================================================== */
export const MOCK = {
syncedAt: "2026-06-25 21:14 KST",
syncAgo: "22초 전",
uptime: "47d 12h",
statusMessage: "모든 핵심 서비스가 정상 가동 중입니다.",
statusDetail: "음성 합성 지연만 평소보다 약간 높습니다.",
/** 전체 시스템 상태 — ok 면 녹색 펄스, warn/crit 이면 색 변경 */
statusLevel: "ok" as HealthStatus,
};
/** 운영 KPI 5 — 정상은 전부 무채(§6.6 "조용한 게 정상"). API오류만 warn. */
export const KPIS: Kpi[] = [
{ label: "진행 중 세션", value: "14", unit: "건", note: "+5 지난 1시간", noteTone: "pos" },
{ label: "오늘 완료 세션", value: "186", unit: "건", note: "+12% 전일 동시간", noteTone: "pos" },
{ label: "활성 사용자 (24h)", value: "312", unit: "명", note: "학습 287 · 교수 21 · 관리 4" },
{ label: "가동률 (30일)", value: "99.94", unit: "%", note: "SLA 99.9% 충족", noteTone: "pos" },
// 오류가 0 초과 → 이 KPI만 warn 강조 (다른 건 전부 무채)
{ label: "API 오류 (1h)", value: "3", unit: "건", note: "임계 20건 미만", noteTone: "warn", tone: "warn" },
];
export const SERVICES: ServiceHealth[] = [
{
name: "내담자 대화 엔진",
sub: "claude -p · 가드레일",
status: "ok",
load: 0.42,
metric: "42% 부하",
statusLabel: "정상",
},
{
name: "음성 합성 (TTS)",
sub: "스트리밍 · 8개 보이스",
status: "warn",
load: 0.78,
metric: "P95 1.4s",
statusLabel: "지연 주의",
},
{
name: "음성 인식 (STT)",
sub: "실시간 스트림",
status: "ok",
load: 0.36,
metric: "P95 0.6s",
statusLabel: "정상",
},
{
name: "세션·평가 DB",
sub: "PostgreSQL 기본",
status: "ok",
load: 0.51,
metric: "conn 51/200",
statusLabel: "정상",
},
{
name: "평가 작업 큐",
sub: "회기말 리뷰 생성",
status: "ok",
load: 0.18,
metric: "대기 3건",
statusLabel: "정상",
},
];
/** 음성·AI 사용 + 비용 (claude -p / API 텔레메트리). 80% 넘으면 warn. */
export const USAGE: UsageMeter[] = [
{ label: "내담자 추론 토큰", sub: "claude -p · 7일 누적", value: "48.2M", ratio: 0.61, budget: "예산 80M" },
{ label: "평가 토큰", sub: "회기말 리뷰 생성", value: "12.6M", ratio: 0.42, budget: "예산 30M" },
{ label: "음성 합성 분량", sub: "TTS 스트리밍", value: "5,140분", ratio: 0.83, budget: "예산 6,000분" },
{ label: "추정 운영비", sub: "이번 달 누적", value: "$1,284", ratio: 0.71, budget: "예산 $1,800" },
];
export const DISTRIB: DistRow[] = [
{ label: "우울 호소 청소년", count: 412, ratio: 0.88, tone: "clay" },
{ label: "불안·공황 20대", count: 298, ratio: 0.64 },
{ label: "관계 갈등 성인", count: 221, ratio: 0.48 },
{ label: "학업 스트레스 고3", count: 174, ratio: 0.37 },
{ label: "위기 개입 시뮬", count: 112, ratio: 0.25, tone: "warn" },
{ label: "기타", count: 67, ratio: 0.14 },
];
export const ROLES: RoleRow[] = [
{ label: "학습자", sub: "상담 수련생 · 등록 248", color: "#3E7A6E", active: 231, delta: 18 },
{ label: "교수자", sub: "슈퍼바이저 · 등록 34", color: "#3A5BA0", active: 29, delta: 2 },
{ label: "관리자", sub: "운영 · 등록 6", color: "#5B5F6B", active: 4 },
{ label: "페르소나", sub: "가상 내담자 풀", color: "#B0735C", active: 23, delta: 3 },
];
export const PROF_AUDIT: ProfessorAudit[] = [
{ name: "김상담", initial: "김", cohort: "상담실습 A반 · 12명", reviewRate: 0.94, published: 6, lastActive: "8분 전" },
{ name: "정교수", initial: "정", cohort: "상담실습 B반 · 14명", reviewRate: 0.81, published: 4, lastActive: "23분 전" },
{ name: "한지도", initial: "한", cohort: "위기개입 세미나 · 9명", reviewRate: 0.42, published: 1, lastActive: "3일 전", needsAttention: true },
{ name: "오슈퍼", initial: "오", cohort: "집단상담 · 11명", reviewRate: 0.67, published: 3, lastActive: "1시간 전" },
];
/** 7일 세션 추이(단일 라인 차트용 정규화 전 값). 피크는 컴포넌트가 산출. */
export const TREND_7D: { day: string; value: number }[] = [
{ day: "월", value: 142 },
{ day: "화", value: 168 },
{ day: "수", value: 151 },
{ day: "목", value: 203 },
{ day: "금", value: 247 },
{ day: "토", value: 118 },
{ day: "일", value: 186 },
];
export const AUDIT_LOG: AuditEntry[] = [
{
id: "S-20448-1",
time: "21:08:42",
actorName: "김상담",
actorInitial: "김",
actorRole: "슈퍼바이저",
action: "view",
actionLabel: "회기 리뷰 열람",
target: "서연 사례 · 회기 1 리뷰",
targetMeta: "학습자 이수민 · 세션 #S-20448",
geo: "kr",
scope: "학습자 식별",
sensitive: true,
},
{
id: "S-20431-1",
time: "20:51:17",
actorName: "정교수",
actorInitial: "정",
actorRole: "슈퍼바이저",
action: "export",
actionLabel: "리뷰 PDF 내보내기",
target: "불안·공황 20대 · 회기 3",
targetMeta: "학습자 박지훈 · 세션 #S-20431",
geo: "kr",
scope: "원본 포함",
sensitive: true,
},
{
id: "S-20448-2",
time: "20:39:05",
actorName: "김상담",
actorInitial: "김",
actorRole: "슈퍼바이저",
action: "grade",
actionLabel: "슈퍼바이저 코멘트 작성",
target: "감정 반영 구간 코멘트 3건",
targetMeta: "학습자 이수민 · 세션 #S-20448",
geo: "kr",
scope: "평가 작성",
},
{
id: "S-20419-1",
time: "20:22:48",
actorName: "한지도",
actorInitial: "한",
actorRole: "슈퍼바이저",
action: "audio",
actionLabel: "오디오 다시듣기",
target: "관계 갈등 성인 · 회기 2",
targetMeta: "학습자 최영아 · 세션 #S-20419",
geo: "us",
scope: "음성 원본",
sensitive: true,
},
{
id: "S-20431-2",
time: "19:57:31",
actorName: "정교수",
actorInitial: "정",
actorRole: "슈퍼바이저",
action: "edit",
actionLabel: "평가 항목 수정",
target: "'닫힌 질문' 라벨 1건 해제",
targetMeta: "학습자 박지훈 · 세션 #S-20431",
geo: "kr",
scope: "평가 수정",
},
{
id: "C-A-1",
time: "19:40:12",
actorName: "한지도",
actorInitial: "한",
actorRole: "슈퍼바이저",
action: "view",
actionLabel: "학습자 성장 곡선 열람",
target: "담당 그룹 12명 추세",
targetMeta: "2026 봄학기 상담실습 A반",
geo: "kr",
scope: "집계 조회",
},
{
id: "S-20448-3",
time: "19:12:55",
actorName: "김상담",
actorInitial: "김",
actorRole: "슈퍼바이저",
action: "view",
actionLabel: "실시간 세션 관찰",
target: "우울 호소 청소년 · 진행 중",
targetMeta: "학습자 이수민 · 세션 #S-20448",
geo: "kr",
scope: "실시간 열람",
sensitive: true,
},
{
id: "S-20402-1",
time: "18:44:09",
actorName: "오슈퍼",
actorInitial: "오",
actorRole: "슈퍼바이저",
action: "export",
actionLabel: "집계 리포트 내보내기",
target: "집단상담 주간 요약",
targetMeta: "익명 집계 · 11명",
geo: "kr",
scope: "집계 조회",
},
{
id: "S-20415-1",
time: "18:20:31",
actorName: "정교수",
actorInitial: "정",
actorRole: "슈퍼바이저",
action: "audio",
actionLabel: "오디오 다시듣기",
target: "학업 스트레스 고3 · 회기 2",
targetMeta: "학습자 윤하늘 · 세션 #S-20415",
geo: "us",
scope: "음성 원본",
sensitive: true,
},
];

View file

@ -1,167 +0,0 @@
/* =====================================================================
Professor ().
- SparkLine: 테이블 ( N회 ). §6.7 "테이블 안 추세 압축"
- MiniMultiple: small-multiples 1( y축 0-100, ). §6.5/§6.7
철칙: 레이더 . stroke . . .
Professor ui/ ( ).
===================================================================== */
import { useMemo } from "react";
import { trendOf, type Trend } from "../../lib/format";
/** 추세별 라인 stroke 색 토큰. up=차분 무채(기본), down=warn, flat=무채. */
function trackStrokeFor(trend: Trend): string {
// 표 안에서는 라인 자체는 조용히(무채), 추세는 끝점 dot 색으로 신호.
return trend === "down" ? "var(--warn-solid)" : "var(--ink-3)";
}
/** 끝점(최신) dot 색: 하락=crit, 주의=warn, 상승/유지=accent. */
function endDotFor(trend: Trend, lastDelta: number): string {
if (trend === "down") return "var(--crit-solid)";
if (lastDelta > 0) return "var(--accent)";
return "var(--warn-solid)";
}
export interface SparkLineProps {
/** 최근 N회 종합점수(0~100). 마지막이 최신. */
values: number[];
/** 추세 판정용 델타(없으면 last-first). */
delta?: number;
width?: number;
height?: number;
}
/** SparkLine — 7px급 미니 추세선 + 끝점. 표 셀 전용. */
export function SparkLine({ values, delta, width = 70, height = 22 }: SparkLineProps) {
const { points, last, trend, d } = useMemo(() => {
const n = values.length;
if (n === 0) {
return { points: "", last: { x: 0, y: height / 2 }, trend: "flat" as Trend, d: 0 };
}
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const padX = 2;
const padY = 3;
const w = width - padX * 2;
const h = height - padY * 2;
const xs = values.map((_, i) => padX + (n === 1 ? w / 2 : (i / (n - 1)) * w));
// y 반전(점수 높을수록 위). SVG는 위가 0.
const ys = values.map((v) => padY + (1 - (v - min) / span) * h);
const pts = xs.map((x, i) => `${x.toFixed(1)},${ys[i].toFixed(1)}`).join(" ");
const dd = delta ?? values[n - 1] - values[0];
return {
points: pts,
last: { x: xs[n - 1], y: ys[n - 1] },
trend: trendOf(dd, 1.5),
d: dd,
};
}, [values, delta, width, height]);
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} fill="none" aria-hidden="true">
<polyline
points={points}
stroke={trackStrokeFor(trend)}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx={last.x} cy={last.y} r={2.4} fill={endDotFor(trend, d)} />
</svg>
);
}
export interface MiniMultipleProps {
/** 역량 라벨 */
label: string;
/** 시계열 값(0~100), 마지막이 최신 */
values: number[];
/** 최신값(표기). 없으면 values 마지막 */
latest?: number;
/** 추세 화살표용 델타 */
delta: number;
/** 첫 카드에만 y축 눈금(0/50/100) 옅게 표기 */
showAxis?: boolean;
width?: number;
height?: number;
}
/** MiniMultiple — small-multiples 라인 1칸. 공통 y축 0-100 고정(카드 간 비교 정확). */
export function MiniMultiple({
label,
values,
latest,
delta,
showAxis = false,
width = 168,
height = 64,
}: MiniMultipleProps) {
const trend = trendOf(delta, 1.5);
const { points, last } = useMemo(() => {
const n = values.length;
const padX = 2;
const padY = 6;
const w = width - padX * 2;
const h = height - padY * 2;
// 공통 y축 0-100 고정(min/max 자동 아님 — small-multiples 핵심).
const xs = values.map((_, i) => padX + (n <= 1 ? w / 2 : (i / (n - 1)) * w));
const ys = values.map((v) => padY + (1 - v / 100) * h);
const pts = xs.map((x, i) => `${x.toFixed(1)},${ys[i].toFixed(1)}`).join(" ");
return { points: pts, last: { x: xs[n - 1] ?? padX, y: ys[n - 1] ?? h } };
}, [values, width, height]);
const lv = latest ?? values[values.length - 1] ?? 0;
return (
<div className="pf-mm">
<div className="pf-mm__top">
<span className="pf-mm__label">{label}</span>
</div>
<div className="pf-mm__chart">
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} fill="none" preserveAspectRatio="none" aria-hidden="true">
{showAxis ? (
<>
{/* 공통 y축 0/50/100 옅은 가이드(첫 카드만) */}
<line x1="2" y1="6" x2={width - 2} y2="6" stroke="var(--hair)" strokeWidth="1" />
<line x1="2" y1={height / 2} x2={width - 2} y2={height / 2} stroke="var(--hair)" strokeWidth="1" strokeDasharray="2 3" />
<line x1="2" y1={height - 6} x2={width - 2} y2={height - 6} stroke="var(--hair)" strokeWidth="1" />
</>
) : null}
<polyline
points={points}
stroke="var(--ink-2)"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx={last.x} cy={last.y} r="3" fill="var(--accent)" />
</svg>
</div>
<div className="pf-mm__foot">
<span className="pf-mm__val tabular">{Math.round(lv)}</span>
<TrendArrow trend={trend} delta={delta} />
</div>
</div>
);
}
/** 추세 화살표 — 이모지 아님. inline SVG, 색만 시맨틱. */
export function TrendArrow({ trend, delta }: { trend: Trend; delta: number }) {
const cls =
trend === "up" ? "pf-ar pf-ar--up" : trend === "down" ? "pf-ar pf-ar--down" : "pf-ar pf-ar--flat";
const sign = delta > 0 ? `+${delta}` : String(delta);
return (
<span className={cls} aria-label={`변화량 ${sign}`}>
<svg viewBox="0 0 24 24" width={13} height={13} fill="none" stroke="currentColor" strokeWidth={2.25} aria-hidden="true">
{trend === "up" ? (
<polyline points="6 14 12 8 18 14" strokeLinecap="round" strokeLinejoin="round" />
) : trend === "down" ? (
<polyline points="6 10 12 16 18 10" strokeLinecap="round" strokeLinejoin="round" />
) : (
<line x1="5" y1="12" x2="19" y2="12" strokeLinecap="round" />
)}
</svg>
</span>
);
}

View file

@ -1,263 +0,0 @@
/* =====================================================================
Professor (mock) .
mock API() .
lib/api.ts .
/ DESIGN_CONCEPT §6.5 .
===================================================================== */
export interface TriageItem {
id: string;
name: string;
meta: string; // 학번/학년 등 보조
/** triage 사유 — 자연어("왜 올라왔나"). 강조 토큰은 렌더에서 처리. */
reasonLead: string;
/** 수치 변화 시퀀스 표기(있으면 mono 강조) */
trail?: string;
/** 행동 신호(미연습/미열람/이탈) — 우측 메타 */
signal: string;
/** 마지막 연습일 */
lastPracticed: string;
/** 슈퍼바이저가 이미 남긴 코멘트 수(0이면 신규) */
existingComments: number;
}
/** 개입 큐 — 알고리즘이 뽑되 교수가 최종 판단(왜를 노출). 위험도 순. */
export const TRIAGE: TriageItem[] = [
{
id: "20231148",
name: "박서연",
meta: "상담심리 3학년",
reasonLead: "개입정확도 3주 연속 하락",
trail: "61 → 52 → 44",
signal: "5일째 미연습",
lastPracticed: "6/19",
existingComments: 0,
},
{
id: "20231152",
name: "이도현",
meta: "상담심리 3학년",
reasonLead: "위기상담 시뮬 중도이탈 2회",
trail: undefined,
signal: "이탈 직후 미복귀",
lastPracticed: "6/20",
existingComments: 1,
},
{
id: "20221073",
name: "최민준",
meta: "상담심리 4학년",
reasonLead: "라포 점수 급락 · 슈퍼바이저 피드백 7건 미열람",
trail: "79 → 58",
signal: "피드백 7건 대기",
lastPracticed: "6/22",
existingComments: 0,
},
];
export type LearnerState = "crit" | "warn" | "ok";
export interface CompetencyCell {
value: number;
/** 직전 대비 변화량(부호) */
delta: number;
}
export interface LearnerRow {
id: string;
name: string;
sessions: number;
rapport: CompetencyCell;
technique: CompetencyCell;
intervention: CompetencyCell;
empathy: CompetencyCell;
/** 최근 8회 종합점수(sparkline) */
trend: number[];
lastPracticed: string;
state: LearnerState;
/** 위험도 정렬용 스코어(높을수록 위험) */
risk: number;
}
const cell = (value: number, delta: number): CompetencyCell => ({ value, delta });
/** 담당 학습자 현황 — 위험도 순 기본 정렬. (mock, 28명 중 7명 표본) */
export const LEARNERS: LearnerRow[] = [
{
id: "20231148",
name: "박서연",
sessions: 8,
rapport: cell(55, -7),
technique: cell(49, -6),
intervention: cell(44, -8),
empathy: cell(58, -4),
trend: [72, 68, 63, 59, 55, 51, 47, 44],
lastPracticed: "6/19",
state: "crit",
risk: 94,
},
{
id: "20231152",
name: "이도현",
sessions: 14,
rapport: cell(71, 0),
technique: cell(66, -5),
intervention: cell(52, -9),
empathy: cell(70, 1),
trend: [64, 67, 63, 66, 60, 63, 56, 52],
lastPracticed: "6/20",
state: "crit",
risk: 88,
},
{
id: "20221073",
name: "최민준",
sessions: 11,
rapport: cell(58, -21),
technique: cell(70, 1),
intervention: cell(64, 0),
empathy: cell(67, -4),
trend: [79, 77, 74, 72, 68, 64, 61, 58],
lastPracticed: "6/22",
state: "crit",
risk: 81,
},
{
id: "20231130",
name: "정유나",
sessions: 9,
rapport: cell(74, 4),
technique: cell(62, 0),
intervention: cell(59, -6),
empathy: cell(71, 5),
trend: [60, 62, 61, 66, 64, 69, 72, 71],
lastPracticed: "6/23",
state: "warn",
risk: 48,
},
{
id: "20221089",
name: "김수련",
sessions: 12,
rapport: cell(82, 6),
technique: cell(68, 0),
intervention: cell(61, -3),
empathy: cell(75, 5),
trend: [62, 65, 68, 70, 74, 77, 80, 82],
lastPracticed: "6/24",
state: "ok",
risk: 18,
},
{
id: "20231115",
name: "한가람",
sessions: 15,
rapport: cell(88, 4),
technique: cell(79, 7),
intervention: cell(71, 0),
empathy: cell(84, 6),
trend: [74, 76, 79, 80, 83, 84, 86, 88],
lastPracticed: "6/24",
state: "ok",
risk: 9,
},
{
id: "20221061",
name: "오세진",
sessions: 10,
rapport: cell(79, 0),
technique: cell(73, 5),
intervention: cell(68, 4),
empathy: cell(77, 0),
trend: [70, 72, 71, 74, 73, 77, 78, 79],
lastPracticed: "6/23",
state: "ok",
risk: 22,
},
];
export interface CohortCompetency {
label: string;
/** 반 평균(0~100) */
avg: number;
/** 가장 취약 1개만 강조 */
weakest?: boolean;
note?: string;
}
/** 반 전체 역량 분포 = 가로 막대(레이더 아님). 최저만 강조. */
export const COHORT_BARS: CohortCompetency[] = [
{ label: "반영적 경청", avg: 78 },
{ label: "라포 형성", avg: 74 },
{ label: "공감 반영", avg: 71 },
{ label: "개방형 질문", avg: 63 },
{
label: "위기 개입 정확도",
avg: 47,
weakest: true,
note: "28명 중 19명이 자해·위기 신호 장면에서 화제 전환 또는 성급한 안심시키기로 대응. 반 평균 47점.",
},
];
/** 반 평균 역량 추이 = small-multiples(공통 y축 0-100). 최근 6주. */
export interface CohortTrend {
label: string;
values: number[];
delta: number;
}
export const COHORT_TRENDS: CohortTrend[] = [
{ label: "반영적 경청", values: [71, 73, 74, 76, 77, 78], delta: 1 },
{ label: "라포 형성", values: [70, 71, 73, 74, 73, 74], delta: 1 },
{ label: "공감 반영", values: [66, 68, 69, 70, 71, 71], delta: 0 },
{ label: "위기 개입 정확도", values: [58, 55, 53, 51, 49, 47], delta: -2 },
];
/** 검수 대기 회기(슈퍼바이저 코멘트 미작성). 시간순. */
export interface PendingReview {
id: string;
learner: string;
learnerId: string;
caseLabel: string; // 내담자 유형
sessionNo: number;
endedAt: string; // "6/24 14:20"
/** AI가 표시한 살펴볼 순간 수 */
flagged: number;
}
export const PENDING_REVIEWS: PendingReview[] = [
{
id: "s-9921",
learner: "박서연",
learnerId: "20231148",
caseLabel: "위기 · 자해 사고 청소년",
sessionNo: 8,
endedAt: "6/19 16:42",
flagged: 4,
},
{
id: "s-9930",
learner: "이도현",
learnerId: "20231152",
caseLabel: "위기 · 자해 사고 청소년",
sessionNo: 14,
endedAt: "6/20 11:08",
flagged: 3,
},
{
id: "s-9947",
learner: "정유나",
learnerId: "20231130",
caseLabel: "불안 · 시험 공황 대학생",
sessionNo: 9,
endedAt: "6/23 10:15",
flagged: 1,
},
];
export const COHORT_META = {
course: "상담실습 II",
total: 28,
week: 4,
needIntervention: 3,
stable: 25,
};

View file

@ -7,7 +7,7 @@
/ stroke currentColor .
===================================================================== */
import type { ValencePoint } from "./mock";
import type { ReviewValencePoint as ValencePoint } from "../../lib/api";
export interface ValenceChartProps {
client: ValencePoint[];

View file

@ -1,305 +0,0 @@
/* =====================================================================
SessionReview (mock) .
mock , .
(SessionEndResponse/digest) .
(§5.7): // ····.
===================================================================== */
/** 상담 기법 군집 (taxonomy 군집 기반: 관계/탐색/개입/안정/구조화). */
export type TechniqueKind =
| "empathy" // 관계 — 공감/반영적 경청
| "explore" // 탐색 — 개방형 질문
| "reflect" // 탐색 — 반영
| "confront" // 개입 — 직면
| "closed"; // 살펴볼 점 — 닫힌 질문
export interface Technique {
kind: TechniqueKind;
/** 칩에 표시할 라벨 */
label: string;
}
export type Speaker = "learner" | "client";
export type NoteAuthor = "ai" | "instructor";
export interface SupervisorNote {
/** ai = 자동 코멘트, instructor = 교수자 직접 */
author: NoteAuthor;
/** good(잘한 점) → accent-tint, watch(살펴볼 점) → warn-tint */
tone: "good" | "watch";
title: string;
/** 본문. quote 부분은 컴포넌트가 italic 강조 */
body: string;
/** 인용 대안 발화(있으면 italic 강조 블록) */
quote?: string;
}
export interface Turn {
id: string;
/** "MM:SS" 또는 "M:SS" */
ts: string;
speaker: Speaker;
/** 화자 표기 (예: "나 (학습자)", "서연 (내담자)") */
who: string;
text: string;
/** 학습자 발화에만: 감지된 기법 라벨(0~2개) */
techniques?: Technique[];
/** 인라인 슈퍼바이저 노트(있으면 발화 아래 들여쓴 콜아웃) */
note?: SupervisorNote;
}
export interface PhaseSegment {
key: "rapport" | "explore" | "intervene" | "closing";
label: string;
/** 소요 비례 가중치(flex) */
weight: number;
}
export interface ValencePoint {
/** 0~1 정규화 시간축 위치 */
t: number;
/** -1 ~ +1 정서가(valence) */
v: number;
}
export interface RubricRow {
name: string;
/** taxonomy 군집 라벨 (관계/탐색/개입/안정/구조화) */
cluster: string;
/** 0~1 — 빈도+적절성 종합(점수 아님, 막대 길이) */
ratio: number;
/** good = 충분/적절, watch = 과다·과소(살펴볼 점) */
quality: "good" | "watch";
/** 빈도 메타 (예: "8회 · 적절") */
freq: string;
}
export interface GrowthPoint {
title: string;
body: string;
/** 연결된 타임라인 발화 id(클릭 점프) */
jumpTo?: string;
}
export interface SessionReviewData {
client: {
name: string;
initial: string;
persona: string;
};
date: string; // 표시용 한국어
durationLabel: string;
/** 진행 단계 신호 (성장 신호 톤 — 점수 아님) */
reachedPhase: string;
/** SESSION 칸 — 성장 신호 (점수 금지) */
sessionSignal: string;
/** SUPERVISOR 칸 — 검토 상태 */
supervisorState: string;
supervisorName: string;
/** 한 줄 요약. hl 토큰(<hl>…</hl>)으로 accent 강조 구간 표시 */
summary: string;
phases: PhaseSegment[];
/** 단계 막대 시간축 라벨 */
phaseAxis: string[];
valenceAxis: string[];
clientValence: ValencePoint[];
counselorBaseline: ValencePoint[];
turns: Turn[];
rubric: RubricRow[];
goodMoments: GrowthPoint[];
growthPoints: GrowthPoint[];
nextLine: string;
/** AI 내담자 피드백 (italic 1인칭) */
clientFeedback: string;
}
export const MOCK_REVIEW: SessionReviewData = {
client: {
name: "서연",
initial: "서",
persona: "17세 · 우울 호소 청소년",
},
date: "2026-06-25",
durationLabel: "32분 14초",
reachedPhase: "탐색 단계까지 진행",
sessionSignal: "라포 형성 신호 뚜렷",
supervisorState: "검토 대기",
supervisorName: "김",
summary:
"라포는 <hl>안정적으로 형성</hl>됐어요. 다만 탐색 단계에서 닫힌 질문이 몇 차례 반복되면서, 서연이 막 열기 시작한 자기개방이 잠깐씩 멈췄습니다.",
phases: [
{ key: "rapport", label: "라포 형성", weight: 1.4 },
{ key: "explore", label: "탐색", weight: 2.6 },
{ key: "intervene", label: "개입", weight: 1.0 },
{ key: "closing", label: "정리", weight: 0.8 },
],
phaseAxis: ["00:00", "09:12", "24:30", "32:14"],
valenceAxis: ["0분", "8분", "16분", "24분", "32분"],
// 내담자 valence: 위축(낮음) → 자기개방 들어가며 완만히 상승, 닫힌 질문 구간(16~17분) 잠깐 하강 → 회복
clientValence: [
{ t: 0.0, v: -0.55 },
{ t: 0.12, v: -0.4 },
{ t: 0.24, v: -0.18 },
{ t: 0.36, v: 0.05 },
{ t: 0.5, v: -0.32 }, // 닫힌 질문 구간 위축
{ t: 0.62, v: -0.1 },
{ t: 0.76, v: 0.18 },
{ t: 0.9, v: 0.28 },
{ t: 1.0, v: 0.22 },
],
// 상담자 baseline: 안정적 중립 톤 유지(점선)
counselorBaseline: [
{ t: 0.0, v: 0.1 },
{ t: 0.18, v: 0.16 },
{ t: 0.36, v: 0.12 },
{ t: 0.5, v: 0.18 },
{ t: 0.64, v: 0.14 },
{ t: 0.8, v: 0.2 },
{ t: 1.0, v: 0.16 },
],
turns: [
{
id: "t1",
ts: "10:42",
speaker: "learner",
who: "나 (학습자)",
text: "학교에 가는 게 많이 버겁게 느껴지는 것 같아요. 혹시 어떤 순간이 가장 힘든지 조금 더 들려줄 수 있을까요?",
techniques: [{ kind: "explore", label: "탐색 · 개방형 질문" }],
note: {
author: "instructor",
tone: "good",
title: "잘한 점",
body: '서연이 먼저 꺼내기 어려워하던 지점을 압박 없이 열어줬어요. 시간 여유를 둔 표현("조금 더")이 안전감을 만들었습니다.',
},
},
{
id: "t2",
ts: "11:05",
speaker: "client",
who: "서연 (내담자)",
text: "아침에… 눈을 뜨면요. 그냥 천장만 보고 있어요. 일어나야 하는 건 아는데, 몸이 안 움직여요. 그게 제일… 무서워요.",
},
{
id: "t3",
ts: "11:28",
speaker: "learner",
who: "나 (학습자)",
text: "눈을 떠도 몸이 따라주지 않는 그 무력감이, 무섭게까지 느껴지는군요.",
techniques: [{ kind: "empathy", label: "공감 · 반영적 경청" }],
note: {
author: "ai",
tone: "good",
title: "잘한 점",
body: '내담자의 단어("무서워요")를 그대로 반영하며 감정의 강도까지 받아냈어요. 직후 서연의 자기개방이 한 단계 깊어졌습니다.',
},
},
{
id: "t4",
ts: "16:54",
speaker: "learner",
who: "나 (학습자)",
text: "그럼 학교는 그냥 가기 싫은 거예요?",
techniques: [{ kind: "closed", label: "닫힌 질문" }],
note: {
author: "instructor",
tone: "watch",
title: "살펴볼 점",
body: "'예/아니오'로 닫히는 질문이라 서연이 \"그런 건 아니고요…\" 하고 짧게 답했어요. 같은 의도를",
quote:
'"학교를 떠올리면 어떤 마음이 먼저 드는지 들려줄래요?" 처럼 열어두면 자기개방이 이어졌을 거예요.',
},
},
{
id: "t5",
ts: "17:10",
speaker: "client",
who: "서연 (내담자)",
text: "…그런 건 아니고요. 그냥… 모르겠어요.",
},
{
id: "t6",
ts: "21:36",
speaker: "learner",
who: "나 (학습자)",
text: "방금 잠깐 말문이 막혔던 것 같아요. 괜찮아요, 천천히 해도 돼요. 요즘 친구들과 함께 있을 때는 어떤 느낌이 드는지 궁금해요.",
techniques: [
{ kind: "reflect", label: "반영" },
{ kind: "explore", label: "탐색" },
],
note: {
author: "ai",
tone: "good",
title: "잘한 점",
body: "닫힌 질문 뒤 위축을 알아채고 페이스를 늦춘 회복이 좋았어요. 곧바로 열린 질문으로 전환해 대화가 다시 흐르기 시작했습니다.",
},
},
],
rubric: [
{
name: "반영적 경청",
cluster: "관계",
ratio: 0.82,
quality: "good",
freq: "6회 · 적절",
},
{
name: "공감 · 타당화",
cluster: "관계",
ratio: 0.74,
quality: "good",
freq: "5회 · 적절",
},
{
name: "개방형 질문",
cluster: "탐색",
ratio: 0.48,
quality: "watch",
freq: "3회 · 더 늘려보기",
},
{
name: "닫힌 질문",
cluster: "탐색",
ratio: 0.58,
quality: "watch",
freq: "5회 · 다소 잦음",
},
{
name: "침묵 견디기",
cluster: "안정",
ratio: 0.66,
quality: "good",
freq: "여백 활용 양호",
},
],
goodMoments: [
{
title: "감정을 그대로 받아낸 반영",
body: "서연의 표현을 빌려 무력감을 비춰줬고, 직후 자기개방이 깊어졌어요.",
jumpTo: "t3",
},
{
title: "위축을 알아챈 회복",
body: "닫힌 질문 뒤 멈춤을 감지하고 페이스를 늦춰 안전감을 되찾았어요.",
jumpTo: "t6",
},
],
growthPoints: [
{
title: "닫힌 질문을 열어두기",
body: "탐색 단계에서 '예/아니오' 질문이 몇 차례 반복됐어요. 의도는 같아도 열린 형태로 바꿔보면 좋겠어요.",
jumpTo: "t4",
},
{
title: "침묵을 조금 더 견디기",
body: "서연이 망설일 때 바로 다음 질문을 채우기보다, 잠깐의 여백을 두면 스스로 더 말할 공간이 생겨요.",
},
{
title: "감정 단어를 함께 명명하기",
body: '"무섭다"처럼 등장한 감정 단어를 다음 회기에서 조금 더 풀어 물으면 자기이해가 깊어집니다.',
},
],
nextLine:
'"그 마음을 떠올리면, 가장 먼저 어떤 장면이 생각나는지 들려줄래요?"',
clientFeedback:
"선생님이 제 말을 진짜로 들어준다는 느낌이 들 때가 있었어요. 근데 중간에 '학교 가기 싫은 거냐'고 물었을 땐… 제 마음이 그렇게 단순하진 않은데, 하고 살짝 멈칫했어요. 그냥 그때 얼마나 막막했는지를 먼저 알아줬으면 했어요.",
};

View file

@ -12,6 +12,9 @@
max-width: var(--maxw);
margin: 0 auto;
}
.sr-root--empty {
--sr-empty-tone: var(--neutral-sig);
}
/* ── (1) 세션 헤더: 아바타 이니셜 + 메타 + 우측 스탯(성장 신호) ── */
.sr-head {
@ -40,14 +43,14 @@
font-family: var(--font-num);
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
letter-spacing: 0;
color: var(--clay-deep);
background: var(--clay-tint);
}
.sr-head__name {
font-size: var(--fs-h3);
font-weight: 600;
letter-spacing: -0.02em;
letter-spacing: 0;
color: var(--text-strong);
line-height: 1.3;
}
@ -131,16 +134,70 @@
/* ── 한 줄 요약 (24px / 600 — 에디토리얼 미니멀 핵심) ── */
.sr-summary {
margin-top: var(--sp-3);
font-size: var(--fs-h2);
line-height: 1.5;
font-weight: 600;
letter-spacing: -0.02em;
letter-spacing: 0;
color: var(--text-strong);
max-width: 56ch;
}
.sr-summary .sr-hl {
color: var(--accent-deep);
}
.sr-actions {
display: flex;
gap: var(--sp-3);
margin-top: var(--sp-5);
flex-wrap: wrap;
}
.sr-readiness {
margin-top: var(--sp-5);
padding: var(--sp-4) 0;
border-top: 1px solid var(--hair);
border-bottom: 1px solid var(--hair);
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-4);
}
.sr-readiness span {
min-width: 0;
display: grid;
gap: 4px;
}
.sr-readiness b {
color: var(--text-strong);
font-size: var(--fs-sm);
line-height: 1.35;
}
.sr-readiness small {
color: var(--text-muted);
font-size: var(--fs-xs);
font-weight: 600;
}
.sr-card {
min-width: 0;
}
.sr-root--empty .sr-card--side {
padding-top: var(--sp-5);
padding-bottom: var(--sp-5);
}
.sr-empty {
max-width: none;
gap: var(--sp-2);
padding: var(--sp-4);
border-radius: var(--radius);
background: var(--bg-surface-2);
}
.sr-empty .vg-empty__title {
font-size: var(--fs-h3);
line-height: 1.35;
}
.sr-empty .vg-empty__desc {
font-size: var(--fs-sm);
line-height: 1.6;
color: var(--text-body);
}
/* ── 감정 밸런스 타임라인 (SVG 라인 차트) ── */
.sr-chart {
@ -266,20 +323,26 @@
justify-content: space-between;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
flex-wrap: wrap;
}
.sr-tx__filters {
display: inline-flex;
gap: 6px;
flex: none;
}
.sr-chip-toggle {
flex: none;
font-family: var(--font-sans);
font-size: var(--fs-xs);
font-weight: 600;
line-height: 1.35;
color: var(--text-muted);
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: 100px;
padding: 4px 12px;
white-space: nowrap;
word-break: keep-all;
cursor: pointer;
transition:
background var(--dur-base) var(--ease-out),
@ -649,6 +712,12 @@
margin: 0 -8px;
padding: 4px 8px;
}
.sr-session-id {
font-size: var(--fs-xs);
color: var(--text-muted);
font-family: var(--font-num);
overflow-wrap: anywhere;
}
@media (max-width: 1024px) {
.sr-cols {
@ -659,6 +728,54 @@
}
}
@media (max-width: 700px) {
.sr-head {
gap: var(--sp-4);
}
.sr-head__stats {
width: 100%;
justify-content: space-between;
}
.sr-stat {
padding: 0;
flex: 1;
}
.sr-summary {
font-size: 21px;
line-height: 1.48;
}
.sr-readiness {
grid-template-columns: 1fr;
gap: var(--sp-3);
margin-top: var(--sp-4);
padding: var(--sp-3) 0;
}
.sr-actions .vg-btn {
flex: 1 1 140px;
}
.sr-empty {
padding: var(--sp-3);
}
.sr-empty .vg-empty__title {
font-size: var(--fs-h3);
}
}
@media (max-width: 420px) {
.sr-head__id {
align-items: flex-start;
}
.sr-head__meta {
gap: 7px;
}
.sr-summary {
font-size: 20px;
}
.sr-actions {
gap: var(--sp-2);
}
}
@media (prefers-reduced-motion: reduce) {
.sr-note,
.sr-chip-toggle,

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
/* =====================================================================
Toggle settings .
settings.html .toggle .
.
(). · . 200ms ease-out.
ui/ .
===================================================================== */

View file

@ -8,10 +8,10 @@
/* ── 레이아웃: 좌측 섹션 내비(sticky) + 우측 폼 ── */
.vg-set {
display: grid;
grid-template-columns: 220px 1fr;
gap: var(--sp-7);
grid-template-columns: 204px minmax(0, 780px);
gap: var(--sp-6);
align-items: start;
max-width: 1000px;
max-width: 1060px;
}
/* 좌측 섹션 내비 */
@ -21,6 +21,11 @@
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--sp-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--bg-surface);
box-shadow: var(--shadow-sm);
}
.vg-set__nav-kicker {
font-family: var(--font-num);
@ -28,7 +33,7 @@
font-weight: 600;
letter-spacing: 0.08em;
color: var(--text-muted);
padding: 0 12px var(--sp-3);
padding: var(--sp-1) 10px var(--sp-3);
}
.vg-set__nav-item {
display: flex;
@ -71,6 +76,8 @@
/* ── 그룹(섹션 카드) — 헤어라인 + 톤차, 상단 강조선 없음 ── */
.vg-set__group {
scroll-margin-top: calc(var(--topbar-h) + var(--sp-4));
box-shadow: var(--shadow-sm);
min-width: 0;
}
.vg-set__group-head {
margin-bottom: var(--sp-5);
@ -78,7 +85,7 @@
.vg-set__group-title {
font-size: var(--fs-h3);
font-weight: 600;
letter-spacing: -0.01em;
letter-spacing: 0;
color: var(--text-strong);
}
.vg-set__group-desc {
@ -96,6 +103,7 @@
gap: var(--sp-5);
padding: var(--sp-5) 0;
border-top: 1px solid var(--bg-surface-2);
min-width: 0;
}
.vg-set__row:first-of-type {
border-top: none;
@ -105,6 +113,7 @@
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.vg-set__row-label .l {
font-size: var(--fs-sm);
@ -122,6 +131,10 @@
gap: var(--sp-2);
min-width: 0;
}
.vg-set__row-field .vg-input {
min-width: 0;
max-width: 100%;
}
/* ── select (입력과 동일 톤, radius 6px) ── */
.vg-set__select {
@ -227,9 +240,11 @@
line-height: 1.5;
}
/* ── 세그먼트 토글(엔진 모드): 2지선다, 둥근 알약 아님(8px) ── */
/* ── 세그먼트 토글(엔진 모드): 백엔드 지원 모드, 둥근 알약 아님(8px) ── */
.vg-set__seg {
display: inline-flex;
width: min(100%, 520px);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
background: var(--bg-surface-2);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
@ -239,7 +254,9 @@
.vg-set__seg-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
font-family: var(--font-sans);
font-size: var(--fs-sm);
font-weight: 500;
@ -263,6 +280,64 @@
box-shadow: var(--shadow-sm);
}
.vg-set__ops {
display: grid;
grid-template-columns: 10px minmax(0, 1fr);
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
padding: var(--sp-4);
border-radius: var(--radius);
background: var(--info-tint);
color: var(--info-text);
}
.vg-set__ops--ok {
background: var(--pos-tint);
color: var(--pos-text);
}
.vg-set__ops--down {
background: var(--crit-tint);
color: var(--crit-text);
}
.vg-set__ops-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.vg-set__ops b {
display: block;
font-size: var(--fs-sm);
line-height: 1.35;
}
.vg-set__ops span {
display: block;
margin-top: 2px;
font-size: var(--fs-xs);
line-height: 1.45;
overflow-wrap: anywhere;
}
.vg-set__state {
display: grid;
grid-template-columns: 10px minmax(0, 1fr);
align-items: center;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
background: var(--bg-surface-2);
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.5;
}
.vg-set__state-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--neutral-sig);
}
/* ── 라디오형 음성 프리셋 행 ── */
.vg-set__voicelist {
display: flex;
@ -289,6 +364,7 @@
.vg-set__voice.is-on {
border-color: var(--accent);
background: var(--accent-tint);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 10%, transparent);
}
.vg-set__voice-rad {
width: 18px;
@ -399,6 +475,7 @@
align-items: center;
gap: var(--sp-5);
margin-bottom: var(--sp-5);
min-width: 0;
}
.vg-set__avatar {
width: 56px;
@ -417,11 +494,16 @@
font-size: var(--fs-lead);
font-weight: 600;
color: var(--text-strong);
overflow-wrap: anywhere;
}
.vg-set__profile-meta .e {
font-size: var(--fs-sm);
color: var(--text-body);
margin-top: 2px;
overflow-wrap: anywhere;
}
.vg-set__profile-meta {
min-width: 0;
}
.vg-set__profile-meta .badges {
display: flex;
@ -437,6 +519,11 @@
font-size: var(--fs-xs);
color: var(--text-muted);
margin-top: var(--sp-2);
min-width: 0;
}
.vg-set__meta span {
min-width: 0;
overflow-wrap: anywhere;
}
.vg-set__meta code {
font-family: var(--font-num);
@ -457,6 +544,7 @@
flex-direction: row;
flex-wrap: wrap;
gap: 6px;
padding: var(--sp-2);
}
.vg-set__nav-kicker {
display: none;
@ -465,6 +553,21 @@
grid-template-columns: 1fr;
gap: 10px;
}
.vg-set__group,
.vg-set__foot,
.vg-set__foot .vg-btn {
scroll-margin-top: calc(var(--topbar-h) + var(--sp-4));
scroll-margin-bottom: 104px;
}
}
@media (max-width: 720px) {
.vg-set__group,
.vg-set__foot,
.vg-set__foot .vg-btn {
scroll-margin-top: calc(var(--topbar-h) + 70px);
scroll-margin-bottom: var(--sp-7);
}
}
@media (prefers-reduced-motion: reduce) {

View file

@ -23,7 +23,7 @@ body {
color: var(--text-strong);
font-size: var(--fs-body);
line-height: 1.6;
letter-spacing: -0.01em;
letter-spacing: 0;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
@ -90,7 +90,7 @@ svg {
/* ── 공통 유틸 ── */
.tabular {
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
letter-spacing: 0;
}
.visually-hidden {

View file

@ -3,6 +3,8 @@
interface ImportMetaEnv {
/** API 베이스 URL. 기본 "/api" (vite proxy / nginx 가 백엔드로 라우팅). */
readonly VITE_API_BASE?: string;
/** 선택 Cubism Core JS URL. 기본 /live2d/live2dcubismcore.min.js. */
readonly VITE_LIVE2D_CUBISM_CORE?: string;
}
interface ImportMeta {