fix(exit-hold): bar 진행 표시 즉시 반영 및 리셋 애니메이션 수정

맥락:

- 몰입 OFF의 나가기 롱프레스 bar에서 진행 표시가 즉시 보이지 않고 완료 시 0으로 역방향 축소되는 문제를 해결하기 위해

변경사항:

- bar 진행 표시를 JS width 갱신에서 CSS keyframes 기반으로 교체

- 키프레임에 가속 규칙 반영(0.05초 20%, 1.0초 100%)

- 완료 후 100% 상태를 짧게 유지한 뒤 언마운트하도록 훅 상태(isCompleted) 보강

- progress fill의 rounded 캡을 제거해 끝단 직선화

- docs/90_current_state.md, docs/session_brief.md 최신 상태 반영

검증:

- npx tsc --noEmit

세션-상태: bar 롱프레스 진행은 눌렀을 때 즉시 보이고 완료 리셋 시 역방향 축소가 사라짐

세션-다음: 롱프레스 인지성 보조 카피 도입 여부 검토

세션-리스크: bar(CSS)와 ring(JS) 진행 로직이 분리되어 향후 규칙 변경 시 동시 점검 필요
This commit is contained in:
2026-02-27 14:36:31 +09:00
parent 73e7d5004c
commit 20638b69a4
5 changed files with 92 additions and 14 deletions

View File

@@ -32,6 +32,10 @@ Last Updated: 2026-02-27
- 0.05초에 진행률 20%까지 빠르게 상승 - 0.05초에 진행률 20%까지 빠르게 상승
- 1초 유지 시 `나가기(더미)` 토스트 + 몰입 모드 OFF - 1초 유지 시 `나가기(더미)` 토스트 + 몰입 모드 OFF
- 몰입 OFF: 좌→우 fill(bar), 몰입 ON: 원형 ring 진행 표시 - 몰입 OFF: 좌→우 fill(bar), 몰입 ON: 원형 ring 진행 표시
- 롱프레스 bar 진행 표시 버그 수정:
- 눌렀을 때 즉시 fill이 보이도록 CSS keyframes 기반으로 교체
- 완료 후 fill이 0으로 역방향 축소되는 현상 제거(짧은 유지 후 언마운트)
- fill 끝단을 직선 형태로 정리(rounded 캡 제거)
- 몰입 모드 ON 시 `/space` 크롬 정리: - 몰입 모드 ON 시 `/space` 크롬 정리:
- 상단 `Current Room` 블록 숨김 - 상단 `Current Room` 블록 숨김
- 우상단 허브 버튼 소형 아이콘화 - 우상단 허브 버튼 소형 아이콘화
@@ -51,6 +55,7 @@ Last Updated: 2026-02-27
2. 몰입 모드에서 터치 환경(hover 없음) 레일 노출 UX를 보완할지 정책 확정 2. 몰입 모드에서 터치 환경(hover 없음) 레일 노출 UX를 보완할지 정책 확정
3. `/space` 헤더 최소화 스타일을 테마별(밝은 배경) 대비 점검 3. `/space` 헤더 최소화 스타일을 테마별(밝은 배경) 대비 점검
4. 롱프레스 나가기 버튼의 터치 환경 힌트(첫 진입 안내) 필요 여부 판단 4. 롱프레스 나가기 버튼의 터치 환경 힌트(첫 진입 안내) 필요 여부 판단
5. Room 시트 인원수 기반 카피를 분위기형 카피로 치환
## RISKS ## RISKS
@@ -59,6 +64,7 @@ Last Updated: 2026-02-27
- 일부 시트(예: Room)는 아직 인원수 중심 문구가 남아 있어 톤 불일치 가능성 존재 - 일부 시트(예: Room)는 아직 인원수 중심 문구가 남아 있어 톤 불일치 가능성 존재
- safe-area 값이 작은 기기에서는 HUD가 너무 낮게 느껴질 수 있어 세부 조정 여지 존재 - safe-area 값이 작은 기기에서는 HUD가 너무 낮게 느껴질 수 있어 세부 조정 여지 존재
- 롱프레스 인터랙션은 첫 사용자에게 즉시 인지되지 않을 수 있어 시각적 힌트 필요 가능성 있음 - 롱프레스 인터랙션은 첫 사용자에게 즉시 인지되지 않을 수 있어 시각적 힌트 필요 가능성 있음
- bar/ring 진행 표시는 서로 다른 구현(JS/CSS)이라 동기화 규칙 변경 시 회귀 점검이 필요
## CHANGED FILES ## CHANGED FILES
@@ -100,6 +106,7 @@ Last Updated: 2026-02-27
- `src/features/exit-hold/index.ts` - `src/features/exit-hold/index.ts`
- `src/features/exit-hold/model/useHoldToConfirm.ts` - `src/features/exit-hold/model/useHoldToConfirm.ts`
- `src/features/exit-hold/ui/ExitHoldButton.tsx` - `src/features/exit-hold/ui/ExitHoldButton.tsx`
- `src/app/globals.css`
## QUICK VERIFY ## QUICK VERIFY

View File

@@ -27,6 +27,9 @@ Last Updated: 2026-02-27
- 상단 우측 나가기 액션을 클릭형에서 1초 롱프레스형으로 전환했다. - 상단 우측 나가기 액션을 클릭형에서 1초 롱프레스형으로 전환했다.
- 0.05초에 진행률 20%까지 빠르게 상승하는 가속 진행을 적용했다. - 0.05초에 진행률 20%까지 빠르게 상승하는 가속 진행을 적용했다.
- 몰입 OFF는 bar, 몰입 ON은 ring 형태로 진행률을 표시한다. - 몰입 OFF는 bar, 몰입 ON은 ring 형태로 진행률을 표시한다.
- 롱프레스 bar 진행 표시를 CSS keyframes 기반으로 교체해 즉시 가시성을 개선했다.
- 완료 후 fill이 0으로 역방향 축소되는 현상을 제거했다.
- fill 끝단은 직선 형태로 정리했다.
- 몰입 모드 ON 시 상단 룸 블록 숨김, 레일 미니화, HUD 저대비, 비네팅 강화가 적용된다. - 몰입 모드 ON 시 상단 룸 블록 숨김, 레일 미니화, HUD 저대비, 비네팅 강화가 적용된다.
- 이후 작업은 `docs/work.md`를 기준으로 실행한다. - 이후 작업은 `docs/work.md`를 기준으로 실행한다.
@@ -36,6 +39,7 @@ Last Updated: 2026-02-27
- 터치 환경에서 레일 미니 상태가 발견성 낮을 수 있어 UX 보완이 필요할 수 있음 - 터치 환경에서 레일 미니 상태가 발견성 낮을 수 있어 UX 보완이 필요할 수 있음
- safe-area가 작은 기기에서는 HUD 하단 간격 체감이 과도할 수 있어 미세 조정이 필요할 수 있음 - safe-area가 작은 기기에서는 HUD 하단 간격 체감이 과도할 수 있어 미세 조정이 필요할 수 있음
- 롱프레스 인터랙션은 신규 사용자에게 즉시 인지되지 않을 수 있어 보조 카피가 필요할 수 있음 - 롱프레스 인터랙션은 신규 사용자에게 즉시 인지되지 않을 수 있어 보조 카피가 필요할 수 있음
- bar/ring 진행 구현 방식이 달라 향후 진행 규칙 변경 시 회귀 확인이 필요함
## 상세 원문 위치 ## 상세 원문 위치

View File

@@ -37,3 +37,15 @@ body {
transform: translateX(0); transform: translateX(0);
} }
} }
@keyframes exit-hold-bar-fill {
0% {
transform: scaleX(0);
}
5% {
transform: scaleX(0.2);
}
100% {
transform: scaleX(1);
}
}

View File

@@ -1,9 +1,10 @@
'use client'; 'use client';
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
const HOLD_DURATION_MS = 1000; const HOLD_DURATION_MS = 1000;
const BOOST_DURATION_MS = 50; const BOOST_DURATION_MS = 50;
const COMPLETE_HOLD_MS = 160;
const mapProgress = (elapsedMs: number) => { const mapProgress = (elapsedMs: number) => {
if (elapsedMs <= 0) { if (elapsedMs <= 0) {
@@ -20,10 +21,13 @@ const mapProgress = (elapsedMs: number) => {
export const useHoldToConfirm = (onConfirm: () => void) => { export const useHoldToConfirm = (onConfirm: () => void) => {
const frameRef = useRef<number | null>(null); const frameRef = useRef<number | null>(null);
const confirmTimeoutRef = useRef<number | null>(null);
const completeTimeoutRef = useRef<number | null>(null);
const startRef = useRef<number | null>(null); const startRef = useRef<number | null>(null);
const confirmedRef = useRef(false); const confirmedRef = useRef(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [isHolding, setHolding] = useState(false); const [isHolding, setHolding] = useState(false);
const [isCompleted, setCompleted] = useState(false);
const clearFrame = () => { const clearFrame = () => {
if (frameRef.current !== null) { 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(); clearFrame();
clearTimers();
startRef.current = null; startRef.current = null;
confirmedRef.current = false; confirmedRef.current = false;
setHolding(false); setHolding(false);
setCompleted(withCompleted);
setProgress(0); setProgress(0);
}; };
useEffect(() => {
return () => {
clearFrame();
clearTimers();
};
}, []);
const step = () => { const step = () => {
if (startRef.current === null) { if (startRef.current === null) {
return; return;
@@ -52,10 +77,17 @@ export const useHoldToConfirm = (onConfirm: () => void) => {
if (clampedProgress >= 1 && !confirmedRef.current) { if (clampedProgress >= 1 && !confirmedRef.current) {
confirmedRef.current = true; confirmedRef.current = true;
if (confirmTimeoutRef.current !== null) {
window.clearTimeout(confirmTimeoutRef.current);
confirmTimeoutRef.current = null;
}
setHolding(false);
setCompleted(true);
onConfirm(); onConfirm();
window.setTimeout(() => {
reset(); completeTimeoutRef.current = window.setTimeout(() => {
}, 120); reset(false);
}, COMPLETE_HOLD_MS);
return; return;
} }
@@ -63,15 +95,32 @@ export const useHoldToConfirm = (onConfirm: () => void) => {
}; };
const start = () => { const start = () => {
if (isHolding) { if (isHolding || isCompleted) {
return; return;
} }
clearTimers();
clearFrame(); clearFrame();
confirmedRef.current = false; confirmedRef.current = false;
startRef.current = performance.now(); setCompleted(false);
setProgress(0);
setHolding(true); setHolding(true);
startRef.current = performance.now();
frameRef.current = window.requestAnimationFrame(step); 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 = () => { const cancel = () => {
@@ -85,6 +134,7 @@ export const useHoldToConfirm = (onConfirm: () => void) => {
return { return {
progress, progress,
isHolding, isHolding,
isCompleted,
start, start,
cancel, cancel,
}; };

View File

@@ -18,7 +18,7 @@ export const ExitHoldButton = ({
onConfirm, onConfirm,
className, className,
}: ExitHoldButtonProps) => { }: ExitHoldButtonProps) => {
const { progress, isHolding, start, cancel } = useHoldToConfirm(onConfirm); const { progress, isHolding, isCompleted, start, cancel } = useHoldToConfirm(onConfirm);
const ringOffset = RING_CIRCUMFERENCE * (1 - progress); const ringOffset = RING_CIRCUMFERENCE * (1 - progress);
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => { const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
@@ -101,15 +101,20 @@ export const ExitHoldButton = ({
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
onClick={(event) => event.preventDefault()} onClick={(event) => event.preventDefault()}
className={cn( 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, className,
)} )}
> >
<span {isHolding || isCompleted ? (
aria-hidden <span
className="absolute inset-y-0 left-0 rounded-lg bg-sky-200/24 transition-[width] duration-75" aria-hidden
style={{ width: `${progress * 100}%` }} className={cn(
/> 'absolute inset-0 z-0 origin-left transform-gpu bg-sky-200/24 rounded-none',
isHolding && 'animate-[exit-hold-bar-fill_1000ms_linear_forwards]',
)}
style={isCompleted ? { transform: 'scaleX(1)' } : undefined}
/>
) : null}
<span className="relative z-10 inline-flex items-center gap-1"> <span className="relative z-10 inline-flex items-center gap-1">
<span aria-hidden></span> <span aria-hidden></span>
<span></span> <span></span>