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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 진행 구현 방식이 달라 향후 진행 규칙 변경 시 회귀 확인이 필요함
|
||||||
|
|
||||||
## 상세 원문 위치
|
## 상세 원문 위치
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{isHolding || isCompleted ? (
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute inset-y-0 left-0 rounded-lg bg-sky-200/24 transition-[width] duration-75"
|
className={cn(
|
||||||
style={{ width: `${progress * 100}%` }}
|
'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>
|
||||||
|
|||||||
Reference in New Issue
Block a user