세션 종료 UX 정리

This commit is contained in:
Yun Chan 2026-06-27 18:09:48 +09:00
parent f472883c31
commit 1007eaf7d9
15 changed files with 460 additions and 245 deletions

View file

@ -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 }) => {

View file

@ -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<boolean>(() => {
try {
const saved = localStorage.getItem(THEME_KEY);
if (saved) return saved === "dark";
} catch {
/* 무시 */
}
// 기본 라이트 고정(밝은 에디토리얼 톤). OS 다크선호는 따라가지 않는다 — 다크는 명시 토글로만.
return false;
});
const [theme, setTheme] = useState<AppTheme>(() => 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 {

27
apps/web/src/lib/theme.ts Normal file
View file

@ -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());
}

View file

@ -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) {

View file

@ -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<boolean | null>(null);
const [resumedSessionLoaded, setResumedSessionLoaded] = useState(false);
const [voiceAnalyser, setVoiceAnalyser] = useState<AnalyserNode | null>(null);
const [endDialogOpen, setEndDialogOpen] = useState(false);
const [ending, setEnding] = useState(false);
const endCancelRef = useRef<HTMLButtonElement>(null);
const endConfirmRef = useRef<HTMLButtonElement>(null);
const endPreviousFocusRef = useRef<HTMLElement | null>(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() {
<Icon name={paused ? "play" : "pause"} size={15} />
{paused ? "이어가기" : "일시정지"}
</button>
<SlideToEnd onConfirm={() => void handleEnd()} label="밀어서 종료" />
<button
type="button"
className="sx-end-button"
onClick={() => setEndDialogOpen(true)}
disabled={ending}
>
<Icon name="x" size={15} />
</button>
</div>
</div>
{endDialogOpen ? (
<div
className="sx-end-dialog"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !ending) setEndDialogOpen(false);
}}
>
<section
className="sx-end-dialog__panel"
role="dialog"
aria-modal="true"
aria-labelledby="sx-end-dialog-title"
aria-describedby="sx-end-dialog-desc"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="sx-end-dialog__head">
<span className="sx-end-dialog__icon" aria-hidden="true">
<Icon name="review" size={18} />
</span>
<div>
<h2 id="sx-end-dialog-title"> ?</h2>
<p id="sx-end-dialog-desc">
.
</p>
</div>
</div>
<div className="sx-end-dialog__actions">
<button
ref={endCancelRef}
type="button"
className="sx-end-dialog__secondary"
onClick={() => setEndDialogOpen(false)}
disabled={ending}
>
</button>
<button
ref={endConfirmRef}
type="button"
className="sx-end-dialog__danger"
onClick={() => void handleEnd()}
disabled={ending}
>
{ending ? "종료 중" : "종료하고 리뷰 보기"}
</button>
</div>
</section>
</div>
) : null}
{/* 경과 시간(접근성 — 보조 표기. 화면 우상단 톱바는 셸 소관) */}
<span className="sr-only" aria-live="polite" style={{ position: "absolute", left: -9999 }}>
{formatElapsed(elapsed)}

View file

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

View file

@ -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<HTMLDivElement>(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 (
<div
ref={trackRef}
className={"sx-slide-end" + (armed ? " is-armed" : "")}
role="group"
aria-label="회기 종료 — 밀어서 확인"
aria-disabled={disabled || undefined}
>
<span className="sx-slide-end__fill" style={{ width: `${knobLeft + KNOB}px` }} />
<span className="sx-slide-end__track-label">
<Icon name="chevron-right" size={14} />
{label}
</span>
<button
type="button"
className="sx-slide-end__knob"
style={{ left: `${knobLeft}px` }}
onPointerDown={handleDown}
onPointerMove={handleMove}
onPointerUp={handleUp}
onPointerCancel={handleUp}
onKeyDown={handleKey}
aria-label="밀어서 회기 종료 (Enter 로도 종료)"
disabled={disabled}
>
<Icon name="x" size={16} />
</button>
</div>
);
}

View file

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

View file

@ -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<boolean>(() => {
// 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회 동기화

View file

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