세션 종료 UX 정리
This commit is contained in:
parent
f472883c31
commit
1007eaf7d9
15 changed files with 460 additions and 245 deletions
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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회 동기화
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue