diff --git a/docs/90_current_state.md b/docs/90_current_state.md index 9ca88b5..01509a1 100644 --- a/docs/90_current_state.md +++ b/docs/90_current_state.md @@ -32,6 +32,10 @@ Last Updated: 2026-02-27 - 0.05초에 진행률 20%까지 빠르게 상승 - 1초 유지 시 `나가기(더미)` 토스트 + 몰입 모드 OFF - 몰입 OFF: 좌→우 fill(bar), 몰입 ON: 원형 ring 진행 표시 +- 롱프레스 bar 진행 표시 버그 수정: + - 눌렀을 때 즉시 fill이 보이도록 CSS keyframes 기반으로 교체 + - 완료 후 fill이 0으로 역방향 축소되는 현상 제거(짧은 유지 후 언마운트) + - fill 끝단을 직선 형태로 정리(rounded 캡 제거) - 몰입 모드 ON 시 `/space` 크롬 정리: - 상단 `Current Room` 블록 숨김 - 우상단 허브 버튼 소형 아이콘화 @@ -51,6 +55,7 @@ Last Updated: 2026-02-27 2. 몰입 모드에서 터치 환경(hover 없음) 레일 노출 UX를 보완할지 정책 확정 3. `/space` 헤더 최소화 스타일을 테마별(밝은 배경) 대비 점검 4. 롱프레스 나가기 버튼의 터치 환경 힌트(첫 진입 안내) 필요 여부 판단 +5. Room 시트 인원수 기반 카피를 분위기형 카피로 치환 ## RISKS @@ -59,6 +64,7 @@ Last Updated: 2026-02-27 - 일부 시트(예: Room)는 아직 인원수 중심 문구가 남아 있어 톤 불일치 가능성 존재 - safe-area 값이 작은 기기에서는 HUD가 너무 낮게 느껴질 수 있어 세부 조정 여지 존재 - 롱프레스 인터랙션은 첫 사용자에게 즉시 인지되지 않을 수 있어 시각적 힌트 필요 가능성 있음 +- bar/ring 진행 표시는 서로 다른 구현(JS/CSS)이라 동기화 규칙 변경 시 회귀 점검이 필요 ## CHANGED FILES @@ -100,6 +106,7 @@ Last Updated: 2026-02-27 - `src/features/exit-hold/index.ts` - `src/features/exit-hold/model/useHoldToConfirm.ts` - `src/features/exit-hold/ui/ExitHoldButton.tsx` +- `src/app/globals.css` ## QUICK VERIFY diff --git a/docs/session_brief.md b/docs/session_brief.md index bae17fb..7530b91 100644 --- a/docs/session_brief.md +++ b/docs/session_brief.md @@ -27,6 +27,9 @@ Last Updated: 2026-02-27 - 상단 우측 나가기 액션을 클릭형에서 1초 롱프레스형으로 전환했다. - 0.05초에 진행률 20%까지 빠르게 상승하는 가속 진행을 적용했다. - 몰입 OFF는 bar, 몰입 ON은 ring 형태로 진행률을 표시한다. +- 롱프레스 bar 진행 표시를 CSS keyframes 기반으로 교체해 즉시 가시성을 개선했다. + - 완료 후 fill이 0으로 역방향 축소되는 현상을 제거했다. + - fill 끝단은 직선 형태로 정리했다. - 몰입 모드 ON 시 상단 룸 블록 숨김, 레일 미니화, HUD 저대비, 비네팅 강화가 적용된다. - 이후 작업은 `docs/work.md`를 기준으로 실행한다. @@ -36,6 +39,7 @@ Last Updated: 2026-02-27 - 터치 환경에서 레일 미니 상태가 발견성 낮을 수 있어 UX 보완이 필요할 수 있음 - safe-area가 작은 기기에서는 HUD 하단 간격 체감이 과도할 수 있어 미세 조정이 필요할 수 있음 - 롱프레스 인터랙션은 신규 사용자에게 즉시 인지되지 않을 수 있어 보조 카피가 필요할 수 있음 +- bar/ring 진행 구현 방식이 달라 향후 진행 규칙 변경 시 회귀 확인이 필요함 ## 상세 원문 위치 diff --git a/src/app/globals.css b/src/app/globals.css index ec5b457..7e4990c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -37,3 +37,15 @@ body { transform: translateX(0); } } + +@keyframes exit-hold-bar-fill { + 0% { + transform: scaleX(0); + } + 5% { + transform: scaleX(0.2); + } + 100% { + transform: scaleX(1); + } +} diff --git a/src/features/exit-hold/model/useHoldToConfirm.ts b/src/features/exit-hold/model/useHoldToConfirm.ts index 7a3bd61..acd1eb0 100644 --- a/src/features/exit-hold/model/useHoldToConfirm.ts +++ b/src/features/exit-hold/model/useHoldToConfirm.ts @@ -1,9 +1,10 @@ 'use client'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; const HOLD_DURATION_MS = 1000; const BOOST_DURATION_MS = 50; +const COMPLETE_HOLD_MS = 160; const mapProgress = (elapsedMs: number) => { if (elapsedMs <= 0) { @@ -20,10 +21,13 @@ const mapProgress = (elapsedMs: number) => { export const useHoldToConfirm = (onConfirm: () => void) => { const frameRef = useRef(null); + const confirmTimeoutRef = useRef(null); + const completeTimeoutRef = useRef(null); const startRef = useRef(null); const confirmedRef = useRef(false); const [progress, setProgress] = useState(0); const [isHolding, setHolding] = useState(false); + const [isCompleted, setCompleted] = useState(false); const clearFrame = () => { if (frameRef.current !== null) { @@ -32,14 +36,35 @@ export const useHoldToConfirm = (onConfirm: () => void) => { } }; - const reset = () => { + const clearTimers = () => { + if (confirmTimeoutRef.current !== null) { + window.clearTimeout(confirmTimeoutRef.current); + confirmTimeoutRef.current = null; + } + + if (completeTimeoutRef.current !== null) { + window.clearTimeout(completeTimeoutRef.current); + completeTimeoutRef.current = null; + } + }; + + const reset = (withCompleted = false) => { clearFrame(); + clearTimers(); startRef.current = null; confirmedRef.current = false; setHolding(false); + setCompleted(withCompleted); setProgress(0); }; + useEffect(() => { + return () => { + clearFrame(); + clearTimers(); + }; + }, []); + const step = () => { if (startRef.current === null) { return; @@ -52,10 +77,17 @@ export const useHoldToConfirm = (onConfirm: () => void) => { if (clampedProgress >= 1 && !confirmedRef.current) { confirmedRef.current = true; + if (confirmTimeoutRef.current !== null) { + window.clearTimeout(confirmTimeoutRef.current); + confirmTimeoutRef.current = null; + } + setHolding(false); + setCompleted(true); onConfirm(); - window.setTimeout(() => { - reset(); - }, 120); + + completeTimeoutRef.current = window.setTimeout(() => { + reset(false); + }, COMPLETE_HOLD_MS); return; } @@ -63,15 +95,32 @@ export const useHoldToConfirm = (onConfirm: () => void) => { }; const start = () => { - if (isHolding) { + if (isHolding || isCompleted) { return; } + clearTimers(); clearFrame(); confirmedRef.current = false; - startRef.current = performance.now(); + setCompleted(false); + setProgress(0); setHolding(true); + startRef.current = performance.now(); frameRef.current = window.requestAnimationFrame(step); + confirmTimeoutRef.current = window.setTimeout(() => { + if (!confirmedRef.current) { + confirmedRef.current = true; + confirmTimeoutRef.current = null; + setProgress(1); + setHolding(false); + setCompleted(true); + onConfirm(); + + completeTimeoutRef.current = window.setTimeout(() => { + reset(false); + }, COMPLETE_HOLD_MS); + } + }, HOLD_DURATION_MS); }; const cancel = () => { @@ -85,6 +134,7 @@ export const useHoldToConfirm = (onConfirm: () => void) => { return { progress, isHolding, + isCompleted, start, cancel, }; diff --git a/src/features/exit-hold/ui/ExitHoldButton.tsx b/src/features/exit-hold/ui/ExitHoldButton.tsx index 0e67015..3e99726 100644 --- a/src/features/exit-hold/ui/ExitHoldButton.tsx +++ b/src/features/exit-hold/ui/ExitHoldButton.tsx @@ -18,7 +18,7 @@ export const ExitHoldButton = ({ onConfirm, className, }: ExitHoldButtonProps) => { - const { progress, isHolding, start, cancel } = useHoldToConfirm(onConfirm); + const { progress, isHolding, isCompleted, start, cancel } = useHoldToConfirm(onConfirm); const ringOffset = RING_CIRCUMFERENCE * (1 - progress); const handleKeyDown = (event: KeyboardEvent) => { @@ -101,15 +101,20 @@ export const ExitHoldButton = ({ onKeyUp={handleKeyUp} onClick={(event) => event.preventDefault()} className={cn( - 'group relative overflow-hidden rounded-lg bg-white/8 px-2.5 py-1.5 text-xs text-white/82 transition hover:bg-white/14 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80', + 'relative overflow-hidden rounded-lg bg-white/8 px-2.5 py-1.5 text-xs text-white/82 transition hover:bg-white/14 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80', className, )} > - + {isHolding || isCompleted ? ( + + ) : null} 나가기