feat(space): 나가기 버튼 롱프레스 가속 진행 인터랙션 추가

맥락:

- /space에서 실수 이탈을 줄이면서도 명확한 탈출 동선을 유지하기 위해 나가기 액션을 1초 롱프레스 방식으로 변경

변경사항:

- features/exit-hold 추가(useHoldToConfirm, ExitHoldButton)

- 1초 롱프레스와 가속 진행 규칙(0.05초 -> 20%)을 feature 내부에 구현

- 몰입 OFF에서는 bar(좌->우 fill), 몰입 ON에서는 ring 진행 표시로 분기

- 1초 미만 해제/마우스 leave/touch cancel 시 진행률 즉시 리셋

- 완료 시 나가기(더미) 토스트 + 몰입 모드 OFF 동작 연결

- docs/90_current_state.md, docs/session_brief.md 상태 업데이트

검증:

- npx tsc --noEmit

세션-상태: 상단 나가기 액션은 롱프레스 완료 시에만 트리거됨

세션-다음: 롱프레스 인터랙션 인지성 보완용 힌트 카피 도입 여부 검토

세션-리스크: 터치 환경에서 롱프레스 UI 의도를 즉시 이해하지 못할 가능성 있음
This commit is contained in:
2026-02-27 14:29:46 +09:00
parent e2fb720a55
commit 73e7d5004c
7 changed files with 232 additions and 22 deletions

View File

@@ -28,6 +28,10 @@ Last Updated: 2026-02-27
- 타이머 HUD 하단 위치를 safe-area 기반 최소 여백으로 조정 - 타이머 HUD 하단 위치를 safe-area 기반 최소 여백으로 조정
- 몰입 모드 ON 시 상단 액션을 `나가기` 버튼으로 전환 - 몰입 모드 ON 시 상단 액션을 `나가기` 버튼으로 전환
- 클릭 시 토스트 `나가기(더미)` 노출 + 몰입 모드 OFF - 클릭 시 토스트 `나가기(더미)` 노출 + 몰입 모드 OFF
- `/space` 상단 우측 나가기 액션을 롱프레스(1초)로 변경
- 0.05초에 진행률 20%까지 빠르게 상승
- 1초 유지 시 `나가기(더미)` 토스트 + 몰입 모드 OFF
- 몰입 OFF: 좌→우 fill(bar), 몰입 ON: 원형 ring 진행 표시
- 몰입 모드 ON 시 `/space` 크롬 정리: - 몰입 모드 ON 시 `/space` 크롬 정리:
- 상단 `Current Room` 블록 숨김 - 상단 `Current Room` 블록 숨김
- 우상단 허브 버튼 소형 아이콘화 - 우상단 허브 버튼 소형 아이콘화
@@ -46,6 +50,7 @@ Last Updated: 2026-02-27
1. `RoomSheetWidget`/도크 패널의 인원수 기반 UI를 큐레이션형 정보로 재정의 1. `RoomSheetWidget`/도크 패널의 인원수 기반 UI를 큐레이션형 정보로 재정의
2. 몰입 모드에서 터치 환경(hover 없음) 레일 노출 UX를 보완할지 정책 확정 2. 몰입 모드에서 터치 환경(hover 없음) 레일 노출 UX를 보완할지 정책 확정
3. `/space` 헤더 최소화 스타일을 테마별(밝은 배경) 대비 점검 3. `/space` 헤더 최소화 스타일을 테마별(밝은 배경) 대비 점검
4. 롱프레스 나가기 버튼의 터치 환경 힌트(첫 진입 안내) 필요 여부 판단
## RISKS ## RISKS
@@ -53,6 +58,7 @@ Last Updated: 2026-02-27
- 터치 기기에서 레일 미니 상태가 발견성 낮을 수 있어 추가 힌트가 필요할 수 있음 - 터치 기기에서 레일 미니 상태가 발견성 낮을 수 있어 추가 힌트가 필요할 수 있음
- 일부 시트(예: Room)는 아직 인원수 중심 문구가 남아 있어 톤 불일치 가능성 존재 - 일부 시트(예: Room)는 아직 인원수 중심 문구가 남아 있어 톤 불일치 가능성 존재
- safe-area 값이 작은 기기에서는 HUD가 너무 낮게 느껴질 수 있어 세부 조정 여지 존재 - safe-area 값이 작은 기기에서는 HUD가 너무 낮게 느껴질 수 있어 세부 조정 여지 존재
- 롱프레스 인터랙션은 첫 사용자에게 즉시 인지되지 않을 수 있어 시각적 힌트 필요 가능성 있음
## CHANGED FILES ## CHANGED FILES
@@ -91,6 +97,9 @@ Last Updated: 2026-02-27
- `src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx` - `src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx`
- `src/widgets/space-chrome/ui/SpaceChromeWidget.tsx` - `src/widgets/space-chrome/ui/SpaceChromeWidget.tsx`
- `src/features/immersion-mode/model/useImmersionMode.ts` - `src/features/immersion-mode/model/useImmersionMode.ts`
- `src/features/exit-hold/index.ts`
- `src/features/exit-hold/model/useHoldToConfirm.ts`
- `src/features/exit-hold/ui/ExitHoldButton.tsx`
## QUICK VERIFY ## QUICK VERIFY

View File

@@ -24,7 +24,9 @@ Last Updated: 2026-02-27
- `workFlow.md`는 토큰 절약 모드를 사용한다. - `workFlow.md`는 토큰 절약 모드를 사용한다.
- `/space` 하단 사운드 바를 제거하고 오른쪽 `🎧 Sound` 시트로 이동했다. - `/space` 하단 사운드 바를 제거하고 오른쪽 `🎧 Sound` 시트로 이동했다.
- `/space` 헤더 프레임을 축소하고 HUD를 하단 safe-area 기준으로 더 밀착시켰다. - `/space` 헤더 프레임을 축소하고 HUD를 하단 safe-area 기준으로 더 밀착시켰다.
- 몰입 모드 ON에서 상단 우측 액션을 `나가기`로 전환했고, 클릭 시 토스트(더미)와 함께 몰입 모드 OFF 처리한다. - 상단 우측 나가기 액션을 클릭형에서 1초 롱프레스형으로 전환했다.
- 0.05초에 진행률 20%까지 빠르게 상승하는 가속 진행을 적용했다.
- 몰입 OFF는 bar, 몰입 ON은 ring 형태로 진행률을 표시한다.
- 몰입 모드 ON 시 상단 룸 블록 숨김, 레일 미니화, HUD 저대비, 비네팅 강화가 적용된다. - 몰입 모드 ON 시 상단 룸 블록 숨김, 레일 미니화, HUD 저대비, 비네팅 강화가 적용된다.
- 이후 작업은 `docs/work.md`를 기준으로 실행한다. - 이후 작업은 `docs/work.md`를 기준으로 실행한다.
@@ -33,6 +35,7 @@ Last Updated: 2026-02-27
- 네트워크 제한 환경에서는 `npm run build` 시 Google Fonts fetch 실패 가능 - 네트워크 제한 환경에서는 `npm run build` 시 Google Fonts fetch 실패 가능
- 터치 환경에서 레일 미니 상태가 발견성 낮을 수 있어 UX 보완이 필요할 수 있음 - 터치 환경에서 레일 미니 상태가 발견성 낮을 수 있어 UX 보완이 필요할 수 있음
- safe-area가 작은 기기에서는 HUD 하단 간격 체감이 과도할 수 있어 미세 조정이 필요할 수 있음 - safe-area가 작은 기기에서는 HUD 하단 간격 체감이 과도할 수 있어 미세 조정이 필요할 수 있음
- 롱프레스 인터랙션은 신규 사용자에게 즉시 인지되지 않을 수 있어 보조 카피가 필요할 수 있음
## 상세 원문 위치 ## 상세 원문 위치

View File

@@ -0,0 +1 @@
export * from './ui/ExitHoldButton';

View File

@@ -0,0 +1,91 @@
'use client';
import { useRef, useState } from 'react';
const HOLD_DURATION_MS = 1000;
const BOOST_DURATION_MS = 50;
const mapProgress = (elapsedMs: number) => {
if (elapsedMs <= 0) {
return 0;
}
if (elapsedMs <= BOOST_DURATION_MS) {
return 0.2 * (elapsedMs / BOOST_DURATION_MS);
}
const tailElapsedMs = Math.min(elapsedMs - BOOST_DURATION_MS, HOLD_DURATION_MS - BOOST_DURATION_MS);
return 0.2 + 0.8 * (tailElapsedMs / (HOLD_DURATION_MS - BOOST_DURATION_MS));
};
export const useHoldToConfirm = (onConfirm: () => void) => {
const frameRef = useRef<number | null>(null);
const startRef = useRef<number | null>(null);
const confirmedRef = useRef(false);
const [progress, setProgress] = useState(0);
const [isHolding, setHolding] = useState(false);
const clearFrame = () => {
if (frameRef.current !== null) {
window.cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
const reset = () => {
clearFrame();
startRef.current = null;
confirmedRef.current = false;
setHolding(false);
setProgress(0);
};
const step = () => {
if (startRef.current === null) {
return;
}
const elapsedMs = performance.now() - startRef.current;
const nextProgress = mapProgress(elapsedMs);
const clampedProgress = Math.min(nextProgress, 1);
setProgress(clampedProgress);
if (clampedProgress >= 1 && !confirmedRef.current) {
confirmedRef.current = true;
onConfirm();
window.setTimeout(() => {
reset();
}, 120);
return;
}
frameRef.current = window.requestAnimationFrame(step);
};
const start = () => {
if (isHolding) {
return;
}
clearFrame();
confirmedRef.current = false;
startRef.current = performance.now();
setHolding(true);
frameRef.current = window.requestAnimationFrame(step);
};
const cancel = () => {
if (!isHolding) {
return;
}
reset();
};
return {
progress,
isHolding,
start,
cancel,
};
};

View File

@@ -0,0 +1,119 @@
'use client';
import type { KeyboardEvent } from 'react';
import { cn } from '@/shared/lib/cn';
import { useHoldToConfirm } from '../model/useHoldToConfirm';
interface ExitHoldButtonProps {
variant: 'bar' | 'ring';
onConfirm: () => void;
className?: string;
}
const RING_RADIUS = 13;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;
export const ExitHoldButton = ({
variant,
onConfirm,
className,
}: ExitHoldButtonProps) => {
const { progress, isHolding, start, cancel } = useHoldToConfirm(onConfirm);
const ringOffset = RING_CIRCUMFERENCE * (1 - progress);
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
start();
}
};
const handleKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
cancel();
}
};
if (variant === 'ring') {
return (
<button
type="button"
aria-label="길게 눌러 나가기"
onMouseDown={start}
onMouseUp={cancel}
onMouseLeave={cancel}
onTouchStart={start}
onTouchEnd={cancel}
onTouchCancel={cancel}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onClick={(event) => event.preventDefault()}
className={cn(
'relative inline-flex h-9 w-9 items-center justify-center rounded-full bg-white/8 text-white/74 transition hover:bg-white/14 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80',
isHolding && 'bg-white/16',
className,
)}
>
<svg
aria-hidden
className="-rotate-90 absolute inset-0 h-9 w-9"
viewBox="0 0 32 32"
>
<circle
cx="16"
cy="16"
r={RING_RADIUS}
fill="none"
stroke="rgba(255,255,255,0.2)"
strokeWidth="2"
/>
<circle
cx="16"
cy="16"
r={RING_RADIUS}
fill="none"
stroke="rgba(186,230,253,0.92)"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={RING_CIRCUMFERENCE}
strokeDashoffset={ringOffset}
/>
</svg>
<span aria-hidden className="relative z-10 text-[12px]">
</span>
</button>
);
}
return (
<button
type="button"
aria-label="길게 눌러 나가기"
onMouseDown={start}
onMouseUp={cancel}
onMouseLeave={cancel}
onTouchStart={start}
onTouchEnd={cancel}
onTouchCancel={cancel}
onKeyDown={handleKeyDown}
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',
className,
)}
>
<span
aria-hidden
className="absolute inset-y-0 left-0 rounded-lg bg-sky-200/24 transition-[width] duration-75"
style={{ width: `${progress * 100}%` }}
/>
<span className="relative z-10 inline-flex items-center gap-1">
<span aria-hidden></span>
<span></span>
</span>
</button>
);
};

View File

@@ -1,17 +1,17 @@
import Link from 'next/link'; import { ExitHoldButton } from '@/features/exit-hold';
interface SpaceChromeWidgetProps { interface SpaceChromeWidgetProps {
roomName: string; roomName: string;
vibeLabel: string; vibeLabel: string;
isImmersionMode: boolean; isImmersionMode: boolean;
onExitImmersionMode: () => void; onExitRequested: () => void;
} }
export const SpaceChromeWidget = ({ export const SpaceChromeWidget = ({
roomName, roomName,
vibeLabel, vibeLabel,
isImmersionMode, isImmersionMode,
onExitImmersionMode, onExitRequested,
}: SpaceChromeWidgetProps) => { }: SpaceChromeWidgetProps) => {
return ( return (
<div className="px-4 pt-2 sm:px-6"> <div className="px-4 pt-2 sm:px-6">
@@ -23,23 +23,10 @@ export const SpaceChromeWidget = ({
<p className="text-sm font-medium tracking-tight text-white/90">VibeRoom</p> <p className="text-sm font-medium tracking-tight text-white/90">VibeRoom</p>
</div> </div>
{isImmersionMode ? ( <ExitHoldButton
<button variant={isImmersionMode ? 'ring' : 'bar'}
type="button" onConfirm={onExitRequested}
onClick={onExitImmersionMode} />
className="inline-flex items-center gap-1 rounded-md bg-white/8 px-2 py-1 text-[11px] text-white/74 transition hover:bg-white/14 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80"
>
<span aria-hidden></span>
<span></span>
</button>
) : (
<Link
href="/app"
className="rounded-lg bg-white/8 px-2.5 py-1.5 text-xs text-white/82 transition hover:bg-white/14 hover:text-white"
>
</Link>
)}
</header> </header>
{!isImmersionMode ? ( {!isImmersionMode ? (

View File

@@ -66,7 +66,7 @@ export const SpaceSkeletonWidget = () => {
roomName={room.name} roomName={room.name}
vibeLabel={room.vibeLabel} vibeLabel={room.vibeLabel}
isImmersionMode={isImmersionMode} isImmersionMode={isImmersionMode}
onExitImmersionMode={exitImmersionMode} onExitRequested={exitImmersionMode}
/> />
<main className="flex-1" /> <main className="flex-1" />