diff --git a/.gitignore b/.gitignore index f7aef58..cd320d8 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ apps/web/_pptr*.cjs # 로컬 dev 서버 로그(scripts/dev-up.ps1) .devlogs/ + +# 로컬 시각 검증 산출물 +docs/design-verification/ diff --git a/apps/web/e2e/session-layout.spec.ts b/apps/web/e2e/session-layout.spec.ts index 6e011d2..f8c3970 100644 --- a/apps/web/e2e/session-layout.spec.ts +++ b/apps/web/e2e/session-layout.spec.ts @@ -204,7 +204,7 @@ async function expectMainControlsUnclipped(page: Page) { { selector: ".sx-controlbar .sx-mic", parent: ".sx-controlbar" }, { selector: ".sx-controlbar .sx-segmented", parent: ".sx-controlbar" }, { selector: ".sx-controlbar .sx-pause", parent: ".sx-controlbar" }, - { selector: ".sx-controlbar .sx-slide-end", parent: ".sx-controlbar" }, + { selector: ".sx-controlbar .sx-end-button", parent: ".sx-controlbar" }, ]; return controls.map(({ selector, parent }) => { diff --git a/apps/web/src/components/shell/Topbar.tsx b/apps/web/src/components/shell/Topbar.tsx index 735f821..6e0bc65 100644 --- a/apps/web/src/components/shell/Topbar.tsx +++ b/apps/web/src/components/shell/Topbar.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { Icon } from "../ui/Icon"; import { useAuth, roleLabel } from "../../lib/auth"; +import { applyTheme, readInitialTheme, type AppTheme } from "../../lib/theme"; /** Vignette 워드마크 — 비네트(조리개) inline SVG. dev_dashboard 마크 계승. */ function BrandMark() { @@ -19,31 +20,13 @@ function BrandMark() { ); } -const THEME_KEY = "vignette.theme"; - /** 라이트/다크 토글 (data-theme 반영). 토큰만으로 전환. */ function useTheme(): [boolean, () => void] { - const [dark, setDark] = useState(() => { - try { - const saved = localStorage.getItem(THEME_KEY); - if (saved) return saved === "dark"; - } catch { - /* 무시 */ - } - // 기본 라이트 고정(밝은 에디토리얼 톤). OS 다크선호는 따라가지 않는다 — 다크는 명시 토글로만. - return false; - }); + const [theme, setTheme] = useState(() => readInitialTheme()); useEffect(() => { - const root = document.documentElement; - if (dark) root.setAttribute("data-theme", "dark"); - else root.removeAttribute("data-theme"); - try { - localStorage.setItem(THEME_KEY, dark ? "dark" : "light"); - } catch { - /* 무시 */ - } - }, [dark]); - return [dark, () => setDark((d) => !d)]; + applyTheme(theme); + }, [theme]); + return [theme === "dark", () => setTheme((current) => (current === "dark" ? "light" : "dark"))]; } function initials(name?: string | null): string { diff --git a/apps/web/src/lib/theme.ts b/apps/web/src/lib/theme.ts new file mode 100644 index 0000000..d0c7c30 --- /dev/null +++ b/apps/web/src/lib/theme.ts @@ -0,0 +1,27 @@ +export type AppTheme = "light" | "dark"; + +export const THEME_KEY = "vignette.theme"; + +export function readInitialTheme(): AppTheme { + try { + const saved = localStorage.getItem(THEME_KEY); + if (saved === "light" || saved === "dark") return saved; + } catch { + /* localStorage 접근 불가 환경에서는 기본값 사용 */ + } + // 이번 시각 검증은 어두운 훈련 화면을 기본 표면으로 삼는다. 사용자가 바꾸면 저장값을 우선한다. + return "dark"; +} + +export function applyTheme(theme: AppTheme) { + document.documentElement.setAttribute("data-theme", theme); + try { + localStorage.setItem(THEME_KEY, theme); + } catch { + /* 저장 실패는 렌더링을 막지 않는다. */ + } +} + +export function initTheme() { + applyTheme(readInitialTheme()); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9855285..06f11b2 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,6 +8,9 @@ import "./components/ui/ui.css"; import "./components/shell/shell.css"; import App from "./App"; +import { initTheme } from "./lib/theme"; + +initTheme(); const rootEl = document.getElementById("root"); if (!rootEl) { diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index 784002f..f5a1427 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -4,7 +4,7 @@ · 좌(296): 회기단계 세로 트랙커 + 내담자 컨텍스트 카드 · 중앙: 어두운 STAGE(bg-stage) — ClientAvatar + 음성 오브(4상태) + 실시간 자막(대본) · 우(300): 라이브 신호(앰비언트 도트 1개, 6초 페이드) + 내담자 상태 미터 + 안전 점검 - · 하단(80): 마이크 + 일시정지 + [몰입|은은|코칭] segmented + 밀어서 종료 + · 하단(80): 마이크 + 일시정지 + [몰입|은은|코칭] segmented + 종료 확인 실제 STT/TTS 는 voice 트랙 소관. 여기선 UI + 텍스트 입력 대체 경로로 1턴 왕복: lib/api 의 sessionApi.stream(POST SSE)으로 @@ -37,7 +37,6 @@ import { } from "../lib/api"; import { useAuth } from "../lib/auth"; import { formatElapsed, formatTimecode, clamp01 } from "../lib/format"; -import { SlideToEnd } from "./session/SlideToEnd"; import "./session/session.css"; /* ── 도메인 상수/타입 ───────────────────────────────────────────────── */ @@ -428,6 +427,11 @@ export default function Session() { const [voiceAvailable, setVoiceAvailable] = useState(null); const [resumedSessionLoaded, setResumedSessionLoaded] = useState(false); const [voiceAnalyser, setVoiceAnalyser] = useState(null); + const [endDialogOpen, setEndDialogOpen] = useState(false); + const [ending, setEnding] = useState(false); + const endCancelRef = useRef(null); + const endConfirmRef = useRef(null); + const endPreviousFocusRef = useRef(null); // ── 라이브 신호(앰비언트 도트) ── const [liveSignal, setLiveSignal] = useState<{ tone: SignalTone; text: string } | null>(null); @@ -1187,21 +1191,62 @@ export default function Session() { }; }, [shutdownVoice]); - // 회기 종료(밀어서 확인) — end 호출 후 현재 화면을 정지 상태로 둔다. + useEffect(() => { + if (!endDialogOpen) return; + endPreviousFocusRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + window.setTimeout(() => endCancelRef.current?.focus(), 0); + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape" && !ending) { + setEndDialogOpen(false); + return; + } + if (event.key === "Tab") { + const controls = [endCancelRef.current, endConfirmRef.current].filter( + (control): control is HTMLButtonElement => control !== null && !control.disabled, + ); + if (controls.length === 0) return; + const first = controls[0]; + const last = controls[controls.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [endDialogOpen, ending]); + + useEffect(() => { + if (endDialogOpen || ending) return; + endPreviousFocusRef.current?.focus(); + endPreviousFocusRef.current = null; + }, [endDialogOpen, ending]); + + // 회기 종료 — 명시 확인 후 end 호출, 현재 화면을 정지 상태로 둔다. const handleEnd = useCallback(async () => { + if (ending) return; + setEnding(true); try { if (liveSessionId) await sessionApi.end(liveSessionId); } catch { /* 종료 실패는 UI 종료 처리를 막지 않는다. */ } + setEndDialogOpen(false); setMicOn(false); setPaused(true); setAvatarState("idle"); pushSignal("neutral", "회기 종료"); if (liveSessionId) { navigate(`/learn/session/${liveSessionId}/review`, { replace: true }); + } else { + setEnding(false); } - }, [liveSessionId, navigate, pushSignal]); + }, [ending, liveSessionId, navigate, pushSignal]); const meters = metersFromOpenness(openness); @@ -1904,10 +1949,69 @@ export default function Session() { {paused ? "이어가기" : "일시정지"} - void handleEnd()} label="밀어서 종료" /> + + {endDialogOpen ? ( +
{ + if (event.target === event.currentTarget && !ending) setEndDialogOpen(false); + }} + > +
event.stopPropagation()} + > +
+ +
+

회기를 종료할까?

+

+ 종료하면 현재 축어록을 저장하고 바로 회기 리뷰 화면으로 이동한다. +

+
+
+
+ + +
+
+
+ ) : null} + {/* 경과 시간(접근성 — 보조 표기. 화면 우상단 톱바는 셸 소관) */} 경과 {formatElapsed(elapsed)} diff --git a/apps/web/src/pages/session-review/session-review.css b/apps/web/src/pages/session-review/session-review.css index 87e62fd..a042761 100644 --- a/apps/web/src/pages/session-review/session-review.css +++ b/apps/web/src/pages/session-review/session-review.css @@ -969,6 +969,62 @@ overflow-wrap: anywhere; } +[data-theme="dark"] .sr-root::before { + opacity: 0.1; + filter: saturate(0.9) contrast(1.06); +} +[data-theme="dark"] .sr-head, +[data-theme="dark"] .sr-card, +[data-theme="dark"] .sr-overview { + background: + linear-gradient(180deg, rgba(27, 42, 38, 0.92), rgba(18, 31, 28, 0.96)), + var(--asset-warm-elements) center / cover no-repeat; + border-color: rgba(203, 227, 220, 0.13); + box-shadow: + 0 16px 42px rgba(3, 9, 8, 0.24), + inset 0 1px 0 rgba(255, 255, 255, 0.035); +} +[data-theme="dark"] .sr-card--transcript { + background: + linear-gradient(180deg, rgba(13, 24, 22, 0.98), rgba(11, 19, 17, 0.98)), + var(--asset-warm-elements) center / cover no-repeat; +} +[data-theme="dark"] .sr-stat, +[data-theme="dark"] .sr-readiness span, +[data-theme="dark"] .sr-empty, +[data-theme="dark"] .sr-chip-toggle, +[data-theme="dark"] .sr-ws-item, +[data-theme="dark"] .sr-feedback, +[data-theme="dark"] .sr-nextline { + background: rgba(255, 255, 255, 0.055); + border-color: rgba(203, 227, 220, 0.12); +} +[data-theme="dark"] .sr-tx__head, +[data-theme="dark"] .sr-turn + .sr-turn, +[data-theme="dark"] .sr-readiness, +[data-theme="dark"] .sr-ws-limitations, +[data-theme="dark"] .sr-feedback__src { + border-color: rgba(203, 227, 220, 0.1); +} +[data-theme="dark"] .sr-turn__said--client { + color: rgba(234, 240, 241, 0.74); +} +[data-theme="dark"] .sr-note--ai { + background: rgba(92, 151, 190, 0.12); +} +[data-theme="dark"] .sr-note--warn { + background: rgba(212, 162, 74, 0.12); +} +[data-theme="dark"] .sr-turn--active .sr-turn__said { + background: rgba(111, 179, 164, 0.14); +} +[data-theme="dark"] .sr-feedback--filled { + border-color: rgba(203, 227, 220, 0.12); + background: + linear-gradient(135deg, rgba(12, 24, 21, 0.98), rgba(30, 44, 40, 0.92)), + var(--asset-warm-elements) center / cover no-repeat; +} + @media (max-width: 1180px) { .sr-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/apps/web/src/pages/session/SlideToEnd.tsx b/apps/web/src/pages/session/SlideToEnd.tsx deleted file mode 100644 index b808b31..0000000 --- a/apps/web/src/pages/session/SlideToEnd.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/* ===================================================================== - SlideToEnd — 밀어서 종료 (slide-to-confirm). 세션 페이지 전용 로컬 컴포넌트. - DESIGN_CONCEPT §5.6: 종료는 오발동 방지 위해 밀어서 종료. - 종료는 유일하게 진한 경고색(crit) 허용 영역. - 포인터 드래그로 knob 을 오른쪽 끝까지 밀면 onConfirm 발동. - ===================================================================== */ - -import { useCallback, useRef, useState } from "react"; -import type { PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent } from "react"; -import { Icon } from "../../components/ui"; - -export interface SlideToEndProps { - onConfirm: () => void; - label?: string; - disabled?: boolean; -} - -const KNOB = 36; // knob 폭(px) — session.css .sx-slide-end__knob 와 일치 -const PAD = 3; // knob 상하좌우 여백 - -export function SlideToEnd({ onConfirm, label = "밀어서 종료", disabled }: SlideToEndProps) { - const trackRef = useRef(null); - const draggingRef = useRef(false); - const [progress, setProgress] = useState(0); // 0~1 - const armed = progress > 0.04; - - const maxTravel = useCallback(() => { - const track = trackRef.current; - if (!track) return 0; - return track.clientWidth - KNOB - PAD * 2; - }, []); - - const onPointerMove = useCallback( - (clientX: number) => { - const track = trackRef.current; - if (!track) return; - const rect = track.getBoundingClientRect(); - const x = clientX - rect.left - PAD - KNOB / 2; - const travel = maxTravel(); - const ratio = travel <= 0 ? 0 : Math.min(1, Math.max(0, x / travel)); - setProgress(ratio); - }, - [maxTravel], - ); - - const finish = useCallback(() => { - if (!draggingRef.current) return; - draggingRef.current = false; - setProgress((cur) => { - if (cur >= 0.92) { - // 끝까지 밀림 → 종료 확정 - onConfirm(); - return 1; - } - return 0; // 미달 → 스냅백 - }); - }, [onConfirm]); - - const handleDown = (e: ReactPointerEvent) => { - if (disabled) return; - draggingRef.current = true; - (e.target as Element).setPointerCapture?.(e.pointerId); - onPointerMove(e.clientX); - }; - const handleMove = (e: ReactPointerEvent) => { - if (!draggingRef.current) return; - onPointerMove(e.clientX); - }; - const handleUp = () => finish(); - - // 키보드 접근성: knob 포커스 후 Enter 로 종료(밀기 불가 환경 보조). - const handleKey = (e: ReactKeyboardEvent) => { - if (disabled) return; - if (e.key === "Enter") { - e.preventDefault(); - onConfirm(); - } - }; - - const travel = maxTravel(); - const knobLeft = PAD + progress * travel; - - return ( -
- - - - {label} - - -
- ); -} diff --git a/apps/web/src/pages/session/session.css b/apps/web/src/pages/session/session.css index 5a2f59b..b567ff5 100644 --- a/apps/web/src/pages/session/session.css +++ b/apps/web/src/pages/session/session.css @@ -220,6 +220,17 @@ border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); } +.sx-page--active .sx-panel, +.sx-page--active .sx-mobile-context { + background: + linear-gradient(180deg, rgba(20, 35, 31, 0.92), rgba(13, 25, 22, 0.94)), + var(--asset-warm-elements) center / cover no-repeat; + border-color: rgba(203, 227, 220, 0.13); + box-shadow: + 0 18px 42px rgba(4, 10, 9, 0.24), + inset 0 1px 0 rgba(255, 255, 255, 0.04); + color: rgba(234, 240, 241, 0.78); +} /* 컬럼 공통 */ .sx-col { @@ -596,9 +607,14 @@ overflow: hidden; } .sx-page--active .sx-transcript { - background: rgba(251, 250, 248, 0.97); - border-color: rgba(255, 255, 255, 0.16); - box-shadow: 0 14px 34px rgba(7, 16, 14, 0.16); + background: + linear-gradient(180deg, rgba(20, 35, 31, 0.94), rgba(12, 23, 20, 0.96)), + var(--asset-warm-elements) center / cover no-repeat; + border-color: rgba(203, 227, 220, 0.13); + box-shadow: + 0 18px 42px rgba(4, 10, 9, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.04); + color: var(--text-body); } .sx-transcript__head { display: flex; @@ -1109,6 +1125,74 @@ .sx-page--active .sx-pause:hover { background: rgba(255, 255, 255, 0.12); } + +.sx-page--active .sx-ctx__row, +.sx-page--active .sx-compose, +.sx-page--active .sx-meter + .sx-meter, +.sx-page--active .sx-safety { + border-color: rgba(203, 227, 220, 0.1); +} +.sx-page--active .sx-chip, +.sx-page--active .sx-signal__one, +.sx-page--active .sx-signal__rest, +.sx-page--active .sx-mobile-context__row span, +.sx-page--active .sx-mobile-context__brief span { + background: rgba(255, 255, 255, 0.055); + color: rgba(234, 240, 241, 0.76); +} +.sx-page--active .sx-chip.is-clay, +.sx-page--active .sx-ctx__pf { + background: rgba(204, 143, 119, 0.16); + color: #e5ad95; +} +.sx-page--active .sx-ctx__nm, +.sx-page--active .sx-vstep.is-cur .sx-vstep__label, +.sx-page--active .sx-mobile-context__row b, +.sx-page--active .sx-mobile-context__brief b { + color: #eaf0f1; +} +.sx-page--active .sx-ctx__rv, +.sx-page--active .sx-vstep.is-done .sx-vstep__label, +.sx-page--active .sx-meter__label, +.sx-page--active .sx-signal__txt { + color: rgba(234, 240, 241, 0.74); +} +.sx-page--active .sx-ctx__mt, +.sx-page--active .sx-ctx__rl, +.sx-page--active .sx-vstep__desc, +.sx-page--active .sx-vstep__t, +.sx-page--active .sx-mobile-context__row small, +.sx-page--active .sx-mobile-context__brief small, +.sx-page--active .sx-safety__desc { + color: rgba(234, 240, 241, 0.52); +} +.sx-page--active .sx-utt.is-client .sx-utt__line { + background: rgba(204, 143, 119, 0.12); + color: #edf5f2; +} +.sx-page--active .sx-utt.is-learner .sx-utt__line { + background: rgba(111, 179, 164, 0.13); + color: #edf5f2; +} +.sx-page--active .sx-utt.is-client .sx-utt__spk { + color: #e5ad95; +} +.sx-page--active .sx-utt.is-learner .sx-utt__spk { + color: #9ed1c6; +} +.sx-page--active .sx-compose textarea { + background: rgba(255, 255, 255, 0.055); + color: #eef6f3; + border-color: rgba(203, 227, 220, 0.18); +} +.sx-page--active .sx-compose textarea::placeholder { + color: rgba(234, 240, 241, 0.36); +} +.sx-page--active .sx-compose .vg-btn--primary:disabled { + background: rgba(255, 255, 255, 0.055); + color: rgba(234, 240, 241, 0.38); + border-color: rgba(203, 227, 220, 0.14); +} /* 마이크 (주 컨트롤, 음성 호흡 펄스) — 원형 예외 허용 */ .sx-mic-block { display: flex; @@ -1260,63 +1344,139 @@ flex: 1; } -/* 밀어서 종료 (slide-to-confirm) — 종료만 유일하게 crit 색 허용 */ .sx-cb-actions { display: flex; align-items: center; gap: 8px; flex: none; } -.sx-slide-end { - position: relative; - width: 176px; - height: 40px; +.sx-end-button { + min-height: 40px; + padding: 9px 13px; + border: 1px solid color-mix(in srgb, var(--crit-solid) 34%, var(--border-subtle)); border-radius: var(--radius); - background: var(--crit-tint); - border: 1px solid var(--crit-tint); - overflow: hidden; - user-select: none; - touch-action: none; -} -.sx-slide-end__track-label { - position: absolute; - inset: 0; - display: flex; + background: color-mix(in srgb, var(--crit-tint) 72%, var(--bg-surface)); + color: var(--crit-text); + display: inline-flex; align-items: center; justify-content: center; - font-size: 13px; - font-weight: 600; - color: var(--crit-text); - pointer-events: none; + gap: 8px; + font-size: 14px; + font-weight: 700; + line-height: 1; + white-space: nowrap; + cursor: pointer; + transition: + background var(--dur-base) var(--ease-out), + border-color var(--dur-base) var(--ease-out), + color var(--dur-base) var(--ease-out); } -.sx-slide-end__fill { - position: absolute; +.sx-end-button:hover:not(:disabled) { + background: color-mix(in srgb, var(--crit-tint) 86%, var(--bg-surface)); + border-color: var(--crit-solid); +} +.sx-end-button:disabled { + cursor: not-allowed; + opacity: 0.6; +} +.sx-page--active .sx-end-button { + background: rgba(216, 100, 89, 0.13); + border-color: rgba(232, 144, 134, 0.32); + color: #f1b2aa; +} +.sx-page--active .sx-end-button:hover:not(:disabled) { + background: rgba(216, 100, 89, 0.19); + border-color: rgba(232, 144, 134, 0.5); + color: #ffd1cc; +} + +.sx-end-dialog { + position: fixed; inset: 0; - background: color-mix(in srgb, var(--crit-solid) 16%, transparent); - width: 0; - pointer-events: none; + z-index: 80; + display: grid; + place-items: center; + padding: var(--sp-4); + background: rgba(3, 9, 8, 0.62); + backdrop-filter: blur(8px); } -.sx-slide-end__knob { - position: absolute; - top: 3px; - left: 3px; - width: 34px; - height: 34px; - border-radius: 6px; - background: var(--surface); - border: 1px solid var(--crit-tint); - display: flex; +.sx-end-dialog__panel { + width: min(100%, 420px); + padding: var(--sp-5); + border: 1px solid rgba(203, 227, 220, 0.16); + border-radius: var(--radius-lg); + background: + linear-gradient(180deg, rgba(24, 38, 35, 0.98), rgba(13, 24, 21, 0.98)), + var(--asset-warm-elements) center / cover no-repeat; + box-shadow: 0 26px 70px rgba(0, 0, 0, 0.42); + color: var(--text-body); +} +.sx-end-dialog__head { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--sp-3); + align-items: start; +} +.sx-end-dialog__icon { + width: 36px; + height: 36px; + border-radius: var(--radius); + display: inline-flex; align-items: center; justify-content: center; - color: var(--crit-text); - cursor: grab; - box-shadow: var(--shadow-sm); + background: rgba(216, 100, 89, 0.15); + color: #f1b2aa; } -.sx-slide-end__knob:active { - cursor: grabbing; +.sx-end-dialog h2 { + margin: 0; + color: var(--text-strong); + font-size: 19px; + font-weight: 760; + line-height: 1.35; } -.sx-slide-end.is-armed .sx-slide-end__track-label { - opacity: 0; +.sx-end-dialog p { + margin: 6px 0 0; + color: var(--text-muted); + font-size: var(--fs-sm); + line-height: 1.55; +} +.sx-end-dialog__actions { + display: flex; + justify-content: flex-end; + gap: var(--sp-2); + margin-top: var(--sp-5); + flex-wrap: wrap; +} +.sx-end-dialog__secondary, +.sx-end-dialog__danger { + min-height: 40px; + padding: 9px 13px; + border-radius: var(--radius); + font-family: var(--font-sans); + font-size: var(--fs-sm); + font-weight: 700; +} +.sx-end-dialog__secondary { + border: 1px solid rgba(203, 227, 220, 0.16); + background: rgba(255, 255, 255, 0.06); + color: var(--text-body); +} +.sx-end-dialog__danger { + border: 1px solid transparent; + background: var(--crit-solid); + color: #fff; +} +.sx-end-dialog__secondary:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + color: var(--text-strong); +} +.sx-end-dialog__danger:hover:not(:disabled) { + background: var(--crit-text); +} +.sx-end-dialog__secondary:disabled, +.sx-end-dialog__danger:disabled { + cursor: not-allowed; + opacity: 0.62; } /* ── 시작 전 오버레이 (세션 시작) ── */ @@ -1906,28 +2066,10 @@ justify-content: center; font-size: 0; } - /* 종료 = 위험 액션. 라벨을 노출해 'X / >' 가 미완성처럼 보이지 않게 하고, - crit 윤곽으로 일반 액션과 색 위계를 분명히. */ - .sx-page--active .sx-slide-end { - width: 150px; + .sx-page--active .sx-end-button { height: 44px; - border-color: color-mix(in srgb, var(--crit-solid) 42%, transparent); - } - .sx-page--active .sx-slide-end__track-label { - justify-content: flex-end; - gap: 5px; - padding-right: 12px; - font-size: 11.5px; - font-weight: 700; - color: var(--crit-text); - } - .sx-page--active .sx-slide-end__track-label svg { - width: 14px; - height: 14px; - } - .sx-page--active .sx-slide-end__knob { - width: 38px; - height: 38px; + padding: 0 12px; + font-size: 12px; } .sx-cb-spacer { display: none; @@ -2134,8 +2276,8 @@ .sx-page--active .sx-pause { width: 44px; } - .sx-page--active .sx-slide-end { - width: 148px; + .sx-page--active .sx-end-button { + min-width: 116px; } } @@ -2212,13 +2354,10 @@ width: 44px; height: 40px; } - .sx-page--active .sx-slide-end { - width: 140px; + .sx-page--active .sx-end-button { + min-width: 104px; height: 40px; - } - .sx-page--active .sx-slide-end__knob { - width: 34px; - height: 34px; + padding: 0 10px; } } diff --git a/apps/web/src/pages/settings/useTheme.ts b/apps/web/src/pages/settings/useTheme.ts index 38a5f0e..f8b90b0 100644 --- a/apps/web/src/pages/settings/useTheme.ts +++ b/apps/web/src/pages/settings/useTheme.ts @@ -1,44 +1,23 @@ /* ===================================================================== useTheme — 라이트/다크 토글 상태. - Topbar 의 토글과 동일한 저장소 키(vignette.theme)와 동일한 - data-theme 속성(documentElement)을 공유한다 → 두 토글이 충돌 없이 동기화. - tokens.css 의 [data-theme="dark"] 블록만으로 전환된다. + Topbar 와 동일한 lib/theme 단일 경로를 사용한다. ===================================================================== */ import { useCallback, useEffect, useState } from "react"; - -const THEME_KEY = "vignette.theme"; - -function readInitial(): boolean { - try { - const saved = localStorage.getItem(THEME_KEY); - if (saved) return saved === "dark"; - } catch { - /* 무시 */ - } - // 기본 라이트 고정. OS 다크선호는 따라가지 않는다(다크는 명시 토글로만). - return false; -} +import { applyTheme, readInitialTheme } from "../../lib/theme"; /** [dark, setDark] — Topbar 토글과 같은 키/속성을 사용. */ export function useTheme(): [boolean, (next: boolean) => void] { const [dark, setDark] = useState(() => { - // documentElement 가 이미 dark 면 그것을 우선(다른 토글이 방금 바꿨을 수 있음) if (typeof document !== "undefined") { const attr = document.documentElement.getAttribute("data-theme"); if (attr === "dark") return true; + if (attr === "light") return false; } - return readInitial(); + return readInitialTheme() === "dark"; }); useEffect(() => { - const root = document.documentElement; - if (dark) root.setAttribute("data-theme", "dark"); - else root.removeAttribute("data-theme"); - try { - localStorage.setItem(THEME_KEY, dark ? "dark" : "light"); - } catch { - /* 무시 */ - } + applyTheme(dark ? "dark" : "light"); }, [dark]); // 다른 곳(Topbar)에서 속성을 바꾸면 따라잡기 위해 마운트 시 1회 동기화 diff --git a/apps/web/src/styles/tokens.css b/apps/web/src/styles/tokens.css index 985d2e2..f4d6d67 100644 --- a/apps/web/src/styles/tokens.css +++ b/apps/web/src/styles/tokens.css @@ -10,6 +10,7 @@ :root { /* ── Primitive: Neutral (따뜻한 종이 + 쿨 슬레이트) §3.1 ── */ --paper: #fbfaf8; /* 본문 배경: 따뜻한 종이색 (순백 아님) */ + --paper-1: #fbfaf8; /* 구형 페이지 alias */ --paper-2: #f4f2ee; /* 카드/패널 미세 단차 (테두리 대신 톤차) */ --surface: #ffffff; /* 카드 표면 */ --neutral-100: #eceff1; /* 표면 hover / subtle fill */ @@ -126,10 +127,24 @@ /* ── 다크 모드 (gloom 회피 — 충분한 밝은 면 + 절제된 악센트) §3.5 ── */ [data-theme="dark"] { - --bg-app: #131a1e; - --bg-surface: #1a2429; - --bg-surface-2: #222e34; - --bg-tint: #1e2e2a; + --paper: #0f1715; + --paper-1: #111b18; + --paper-2: #17211f; + --surface: #1b2724; + --neutral-100: #24322e; + --neutral-150: #2c3b36; + --neutral-200: #3a4b45; + --neutral-400: #7c8d88; + + --ink: #eef6f3; + --ink-2: #c6d3cf; + --ink-3: #8a9b96; + --hair: #2c3b36; + + --bg-app: #0e1714; + --bg-surface: #17231f; + --bg-surface-2: #22302c; + --bg-tint: #1d302b; --bg-stage: #0e1614; --text-strong: #eaf0f1; --text-body: #c4cfd3; @@ -143,6 +158,8 @@ --accent-tint: #1e2e2a; --accent-bright: #6fb3a4; --clay: #cc8f77; + --clay-deep: #e0aa92; + --clay-tint: #2b211e; --focus-ring: rgba(111, 179, 164, 0.45); --pos-text: #7fd0a0; @@ -186,15 +203,26 @@ } /* 다크 + 역할 동시 적용 시 accent 명도 보정 (가독) */ +[data-theme="dark"] body[data-role="learner"] { + --accent: #6fb3a4; + --accent-deep: #84c2b4; + --accent-bright: #6fb3a4; + --accent-tint: #1e2e2a; + --focus-ring: rgba(111, 179, 164, 0.45); +} +[data-theme="dark"] body[data-role="instructor"], [data-theme="dark"][data-role="instructor"] { --accent: #7d9fde; --accent-deep: #94b1e6; --accent-bright: #7d9fde; --accent-tint: #19222f; + --focus-ring: rgba(125, 159, 222, 0.4); } +[data-theme="dark"] body[data-role="admin"], [data-theme="dark"][data-role="admin"] { --accent: #9aa0ad; --accent-deep: #b0b5c0; --accent-bright: #9aa0ad; --accent-tint: #1d2026; + --focus-ring: rgba(154, 160, 173, 0.4); } diff --git a/docs/DESIGN_CONCEPT.md b/docs/DESIGN_CONCEPT.md index 85110c8..fe338f9 100644 --- a/docs/DESIGN_CONCEPT.md +++ b/docs/DESIGN_CONCEPT.md @@ -492,7 +492,7 @@ function useAvatarMotion(state, affect, analyser) { │ │ 실시간 자막 (아래→위 흐름) │ (최대 1개, 페이드) │ │ │ │ ── 셀프 노트 ── │ ├──────────────────┴──────────────────────────────────┴──────────────────────┤ -│ 하단 컨트롤 바 (80px) 마이크 · 일시정지 · [몰입│은은│코칭] · (밀어서)종료 │ +│ 하단 컨트롤 바 (80px) 마이크 · 일시정지 · [몰입│은은│코칭] · 회기 종료 확인 │ └──────────────────────────────────────────────────────────────────────────┘ ``` @@ -615,7 +615,7 @@ LEFT 패널 세로 트랙커 + 상단 바 가로 미니. **현재 단계만 또 - **마이크 버튼(주 컨트롤):** 56px 원형. 학습자 말하는 중 = `--accent` 채움 + 호흡. 음소거 = 아웃라인. push-to-talk 또는 토글. 상태 텍스트 한 줄("듣고 있어요" / "마이크 꺼짐"). - **일시정지:** 타이머·평가·녹음 모두 정지. 정지 중 stage 차분한 오버레이. -- **종료:** 오발동 방지 — **밀어서 종료(slide-to-confirm)** 또는 0.8s 길게. 종료가 유일하게 진한 경고색(`--crit-solid`) 허용 영역. +- **종료:** 오발동 방지 — 숨은 제스처가 아니라 명시적 **회기 종료** 버튼 + 확인 다이얼로그. 종료가 유일하게 진한 경고색(`--crit-solid`) 허용 영역. - 접근성: 키보드(Space=마이크, P=일시정지), `--focus-ring` 2px. 음성 상태는 색만이 아니라 텍스트 병기(색각 이상 대응). ### 5.7 회기 종료 후 리뷰 화면 (진짜 학습이 일어나는 곳) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 7e15ffc..bcf18cf 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -24,6 +24,7 @@ - 계약 SSOT 2차: `apps/web/src/lib/api.ts`의 수기 DTO 중 auth, persona, admin/user/engine, review leaf, teacher safety/growth leaf 타입을 `apps/web/src/lib/api.gen.ts`의 `ApiSchema` alias로 전환했다. generated optional/default 차이는 화면 form state와 notification default helper로 흡수했다. aggregate 응답 배열 optional 전환과 CI 연결은 후속. 검증: `npm run check:api-types`, `npm run typecheck`. - 턴 런타임 리팩터 2차: `turn_runtime.finalize_completed_turn`으로 REST submit/stream/voice WS의 `record_completed_turn` + `record_safety_event` 호출쌍을 공통화했다. gateway `/v1/stream` route 레벨에서 `token/done/error` SSE 프레임 contract tests를 추가했다. 검증: focused backend 40 passed. - Phase3 artifact checker 강화: `scripts/check-phase3-artifacts.py`가 CSV enum, KPI report 필수 field, approved export의 PII pass·κ/ICC·withdrawn exclusion·consent scope·file sha256을 검증한다. `app/test_phase3_artifact_checker.py` 5 tests 추가. 실제 파일럿 evidence와 steward/legal/IAA gate는 그대로 외부 의존이다. +- 세션 종료 UX/theme SSOT 정리: 드래그형 `SlideToEnd`를 명시 확인 다이얼로그로 교체하고, Topbar/Settings theme 토글을 `lib/theme.ts` 단일 경로로 통합했다. 저장값이 없으면 dark를 기본으로 두고 앱 부팅 시 theme를 먼저 적용해 초기 flash를 줄인다. 검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/layout-visual-gate.spec.ts e2e/session-layout.spec.ts` 15 passed. - M3 인증 claim 1차: Google/SAML/dev-login이 설정 기반 cohort map과 SAML cohort claim을 `cohort_ids`로 넘기고, 관리 사용자 `external_id`는 provider subject 기반(`google:`/`saml:`/`dev:`)으로 저장한다. 운영 SAML 서명검증·기관 claim schema·deprovisioning audit은 후속. - Google OAuth 진단 1차: provider callback error는 `access_denied`/`provider_error`로 분리하고, 로그인 화면은 실패 reason code를 함께 표시한다. 실제 Google 계정 완료 proof는 아직 owner 로그인/storageState가 필요하다. - `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다. diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index f8da445..43d0e0b 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -453,6 +453,7 @@

5차 적용(2026-06-27): 회기 리뷰는 시안의 좌·중·우 작업대 구조에 맞춰 요약/액션 레일, 감정·흐름 중앙 영역, 우측 인사이트 패널로 재배치했다. 모바일에서는 트랜스크립트를 워크시트보다 먼저 노출하고, 사례개념화 워크시트는 제한 높이의 보조 패널로 낮춰 긴 문서처럼 보이지 않게 했다.

6차 적용(2026-06-27): 설정 화면은 글로벌 셸을 숨기고 설정 전용 좌측 레일을 주 내비로 승격했다. 계정, AI 운영, 테마, 음성, 알림 카드를 시안의 차분한 설정 워크스페이스 흐름에 맞춰 단일 컬럼으로 재배치했고, 모바일은 상단 고정 섹션 탭으로 모든 항목이 잘리지 않게 정리했다.

7차 적용(2026-06-27): 로그인 화면은 별도 생성한 래스터 상담실 배경(login-counseling-room.png)을 왼쪽 브랜드 패널에 적용해 시안의 실제 상담실 분위기를 반영했다. 학습자 홈은 모바일 grid 행 계산 때문에 생기던 리스트-상세 간 큰 공백을 제거하고, 회기 준비 보드가 한 흐름으로 이어지게 정리했다.

+

8차 적용(2026-06-27): 다크 테마가 라이트 primitive 토큰과 역할 accent에 덮여 흰 섹션이 섞이던 문제를 수정했다. 앱 부트 시 테마를 먼저 적용하고, 다크 모드에서 paper/surface/hair/ink primitive까지 스왑한다. 상담 세션 진행 화면은 테마 토글과 무관하게 어두운 콘솔 패널로 고정했고, 드래그형 종료 제스처는 명시적 회기 종료 버튼 + 확인 다이얼로그로 교체했다. 회기 리뷰도 동일한 다크 카드/축어록/워크시트 재질로 보정했다.

래스터만 사용이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.
기능 우선메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.
@@ -773,6 +774,7 @@
DONE
레이아웃 감사팀 / 전면 레이아웃 개선

서브에이전트 전담으로 1차 overflow/clipping 수리 후 2차 전면 재설계를 진행했고, 이번 라운드에서 7개 권장 너비 시각 수용까지 닫았다. 세션, 학습자 홈/리뷰, 교수 콘솔, 관리자, 설정 화면을 작업 화면 기준으로 재배치했고, 과한 빈칸·튀어나감·버튼/텍스트 잘림·모바일 테이블 사용성을 줄였다. 엄격한 시각 게이트 + 화면별 적대적 시각 재검수에서 7개 화면 모두 accept(critical/major 0, 회귀 0)를 받아 DONE으로 닫는다.

산출물

docs/ops/layout-redesign-handoff-2026-06-26.md, page-by-page redesign patches, apps/web/e2e/layout-visual-gate.spec.ts

검증

npm run typecheck OK, 엄격 시각 게이트 7 passed, layout focused E2E desktop/mobile 54 passed, 적대적 시각 재검수 7/7 accept·회귀 0.

DONE
엄격한 레이아웃 시각 게이트 구축 · 병렬 수정 · 적대적 재검수

핸드오프가 요구한 7개 권장 너비(390/720/861/900/1024/1280/1440) 시각 수용을 자동 게이트로 고정했다. 7개 재설계 화면을 각 너비에서 렌더링해 가로 overflow 0, 컨트롤 하드클립/텍스트클립 0을 강제하고 화면당 7장 총 49장 풀페이지 스크린샷을 남긴다. 화면별 1에이전트 병렬 시각 리뷰로 49장 직접 판독 → 6개 병렬 수정 에이전트(파일 비중첩)가 결함 수정 → 화면별 적대적 재검수로 해소·무회귀 확정.

산출물

apps/web/e2e/layout-visual-gate.spec.ts, node_modules/.tmp/layout-gate/*.png 49장, 2× workflow(review/verify)·6× fix agent

검증

게이트 7 passed(재스크린샷). 해소된 핵심 결함: 학습자홈 1280/1440 3열 가운데 컬럼 붕괴(critical → 2열 분기 상향 + word-break:keep-all), 교수 'API 404' raw 배너 제거, 리뷰 빈상태 위계 역전, 관리자 스켈레톤, 설정 와이드 좌측 데드존 제거, 세션 모바일 44px 터치타깃. 적대적 재검수 7/7 accept, regression 0.

DONE
레이아웃 cosmetic minor 폴리시 일괄 처리

적대적 재검수 잔여 cosmetic minor를 5개 병렬 폴리시 에이전트로 처리했다. 학습자홈 헬퍼문구 고아 글자, 세션 1024 일시정지 아이콘 정리 + 보내기 버튼 대비 강화, 설정 2x2 칩 행 높이 균일, 교수 검토 큐 카드 갭 제거, 관리자 2열 높이 동기화를 적용했다.

산출물

page-by-page polish patches(LearnerHome/Session/Settings/Professor/Admin)

검증

npm run typecheck OK, 시각 게이트 7 passed, 레이아웃 E2E desktop/mobile 54 passed, session-layout 8/8 — 회귀 0.

+
DONE
세션 종료 UX와 theme SSOT

세션 하단의 드래그형 종료 컴포넌트를 명시 확인 다이얼로그로 바꿔 모바일 오발동과 제스처 실패를 줄였다. Topbar/Settings의 theme 저장·초기화는 lib/theme.ts 단일 경로로 합치고, 저장값이 없으면 dark 기본값을 앱 부팅 전에 적용한다.

산출물

Session.tsx, session.css, session-review.css, lib/theme.ts, settings/useTheme.ts, session-layout.spec.ts

검증

npm run typecheck, npm run build, strict layout gate 7 passed, session-layout desktop/mobile 8 passed, settings desktop/mobile 12 passed, session-review desktop/mobile 2 passed.

DONE
잔여(비차단) — 공용 셸 단일 항목 → 처리

축소 사이드바 세로 구분선이 본문 전체 높이까지 닿지 않던 건을 components/shell/shell.css에서 처리했다. .vg-nav border-right 제거 후 .vg-shell__body 컨테이너 배경 하어라인으로 본문 그리드 전체 높이 구분선을 그리고, 그리드·구분선 폭을 --nav-cur로 동기화. learner-home 로딩 스켈레톤 밀도도 실제 카드 구조 모사로 보강했다.

판정

구현 완료. 검증: npm run typecheck PASS + vite build PASS. 전 페이지 시각 회귀 게이트는 web+api+DB 스택으로 이 워크스테이션 미실행 — 스택 가용 시 1회 시각 확인 권장.

검증 실행 상세 표

요약하지 않고 실행 범위, 명령, 결과를 행 단위로 남긴다.

@@ -828,6 +830,7 @@ Learner/readiness E2Elearner.spec.ts + readiness.spec.ts desktop/mobile14 passed Learner screenshotslearn-empty-desktop.png / learn-empty-mobile-compact.pngdocument overflow 0, empty history visible Session layout/turnsession-layout.spec.ts desktop/mobile8 passed + Session end + theme UXnpm run typecheck, npm run build, layout-visual-gate, session-layout, settings, session-reviewtypecheck/build passed; strict layout gate 7 passed; session-layout desktop/mobile 8 passed; settings desktop/mobile 12 passed; session-review desktop/mobile 2 passed Voice/session focusedvoice + voice-success + session-layout15 passed Layout redesign handoffdocs/ops/layout-redesign-handoff-2026-06-26.mdsubagent scopes, files, verification, remaining visual review recorded Layout redesign focused E2Enpx playwright test e2e/learner.spec.ts e2e/session-layout.spec.ts e2e/session-review.spec.ts e2e/admin.spec.ts e2e/settings.spec.ts e2e/teacher.spec.ts --project=chromium-desktop --project=chromium-mobile54 passed after isolated Admin tablet flake rerun diff --git a/docs/ops/backlog-2026-06-26.md b/docs/ops/backlog-2026-06-26.md index 4851850..3e9a6ec 100644 --- a/docs/ops/backlog-2026-06-26.md +++ b/docs/ops/backlog-2026-06-26.md @@ -13,6 +13,7 @@ ## B1. 비차단 코스메틱 폴리시 - [x] **공용 셸 사이드바 구분선 전체 높이** — (2026-06-26 처리) `.vg-nav border-right`(sticky·뷰포트 높이)를 제거하고, `.vg-shell__body` 컨테이너 배경 하어라인(`linear-gradient` 1px×100%, `--nav-cur`로 그리드·구분선 폭 동기화)으로 본문 그리드 전체 높이를 따라가는 세로 구분선을 그린다. 1024 축소폭은 `--nav-cur: var(--nav-w-collapsed)`로, 720 이하(상단 가로바 전환)는 `background-image:none`으로 정합. `components/shell/shell.css`. **검증: `npm run typecheck` PASS + `vite build` PASS.** 시각 회귀 게이트(gate 7/7 + 포커스 E2E + session-layout 8/8)는 web+api+DB 스택(Docker 미기동·secrets 부재)으로 이 환경 미실행 — 스택 가용 시 1회 시각 확인 권장. +- [x] **세션 종료 UX와 theme SSOT** — (2026-06-27 처리) 오발동과 모바일 조작 불안을 줄이기 위해 세션 하단의 드래그형 `SlideToEnd`를 명시 확인 다이얼로그로 교체했다. Topbar/Settings의 `vignette.theme` 읽기·저장·`data-theme` 적용은 `apps/web/src/lib/theme.ts` 단일 경로로 통합했고, 저장값이 없으면 dark 기본값을 부팅 전에 적용한다. **검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/layout-visual-gate.spec.ts e2e/session-layout.spec.ts` 15 passed.** - [ ] **빈상태 컬럼 높이 여백** — session-review 1280 masonry 우측 컬럼, session-prestart 우측 '진행 초점' 컬럼, learner-home 1440 하단 등 데이터 0건 빈상태에서만 두드러지는 컬럼 높이 불균형/여백. 실데이터면 채워진다. **(미착수, 의도적)** 0건 전용 시각 균형이라 러닝 앱에서의 시각 반복 없이 블라인드 수정 시 악화 위험 → 스택 가용 시 시각 확인하며 처리. 빈상태 전용 균형 레이아웃 채택 자체가 별도 폴리시 결정. - [x] **learner-home 로딩 스켈레톤 밀도(390)** — (2026-06-26 처리) '연습 대상' 로딩 스켈레톤을 빈 div 단일 셰이머에서 실제 카드 구조(마크 박스 + 이름/메타/요약 2줄 플레이스홀더)를 모사하도록 밀도 보강. `LearnerHome.tsx` (`.lh-skel__box`/`.lh-skel__line*`), reduced-motion 분기 동반 갱신. **검증: `npm run typecheck` PASS + `vite build` PASS.**