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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 하단 간격 체감이 과도할 수 있어 미세 조정이 필요할 수 있음
|
||||||
|
- 롱프레스 인터랙션은 신규 사용자에게 즉시 인지되지 않을 수 있어 보조 카피가 필요할 수 있음
|
||||||
|
|
||||||
## 상세 원문 위치
|
## 상세 원문 위치
|
||||||
|
|
||||||
|
|||||||
1
src/features/exit-hold/index.ts
Normal file
1
src/features/exit-hold/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './ui/ExitHoldButton';
|
||||||
91
src/features/exit-hold/model/useHoldToConfirm.ts
Normal file
91
src/features/exit-hold/model/useHoldToConfirm.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
119
src/features/exit-hold/ui/ExitHoldButton.tsx
Normal file
119
src/features/exit-hold/ui/ExitHoldButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user