From 73e7d5004c6337de03a71c67b9f072360bd930ea Mon Sep 17 00:00:00 2001 From: corpi Date: Fri, 27 Feb 2026 14:29:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(space):=20=EB=82=98=EA=B0=80=EA=B8=B0=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EB=A1=B1=ED=94=84=EB=A0=88=EC=8A=A4=20?= =?UTF-8?q?=EA=B0=80=EC=86=8D=20=EC=A7=84=ED=96=89=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=EB=9E=99=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 맥락: - /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 의도를 즉시 이해하지 못할 가능성 있음 --- docs/90_current_state.md | 9 ++ docs/session_brief.md | 5 +- src/features/exit-hold/index.ts | 1 + .../exit-hold/model/useHoldToConfirm.ts | 91 ++++++++++++++ src/features/exit-hold/ui/ExitHoldButton.tsx | 119 ++++++++++++++++++ .../space-chrome/ui/SpaceChromeWidget.tsx | 27 ++-- .../space-shell/ui/SpaceSkeletonWidget.tsx | 2 +- 7 files changed, 232 insertions(+), 22 deletions(-) create mode 100644 src/features/exit-hold/index.ts create mode 100644 src/features/exit-hold/model/useHoldToConfirm.ts create mode 100644 src/features/exit-hold/ui/ExitHoldButton.tsx diff --git a/docs/90_current_state.md b/docs/90_current_state.md index 6b34a22..9ca88b5 100644 --- a/docs/90_current_state.md +++ b/docs/90_current_state.md @@ -28,6 +28,10 @@ Last Updated: 2026-02-27 - 타이머 HUD 하단 위치를 safe-area 기반 최소 여백으로 조정 - 몰입 모드 ON 시 상단 액션을 `나가기` 버튼으로 전환 - 클릭 시 토스트 `나가기(더미)` 노출 + 몰입 모드 OFF +- `/space` 상단 우측 나가기 액션을 롱프레스(1초)로 변경 + - 0.05초에 진행률 20%까지 빠르게 상승 + - 1초 유지 시 `나가기(더미)` 토스트 + 몰입 모드 OFF + - 몰입 OFF: 좌→우 fill(bar), 몰입 ON: 원형 ring 진행 표시 - 몰입 모드 ON 시 `/space` 크롬 정리: - 상단 `Current Room` 블록 숨김 - 우상단 허브 버튼 소형 아이콘화 @@ -46,6 +50,7 @@ Last Updated: 2026-02-27 1. `RoomSheetWidget`/도크 패널의 인원수 기반 UI를 큐레이션형 정보로 재정의 2. 몰입 모드에서 터치 환경(hover 없음) 레일 노출 UX를 보완할지 정책 확정 3. `/space` 헤더 최소화 스타일을 테마별(밝은 배경) 대비 점검 +4. 롱프레스 나가기 버튼의 터치 환경 힌트(첫 진입 안내) 필요 여부 판단 ## RISKS @@ -53,6 +58,7 @@ Last Updated: 2026-02-27 - 터치 기기에서 레일 미니 상태가 발견성 낮을 수 있어 추가 힌트가 필요할 수 있음 - 일부 시트(예: Room)는 아직 인원수 중심 문구가 남아 있어 톤 불일치 가능성 존재 - safe-area 값이 작은 기기에서는 HUD가 너무 낮게 느껴질 수 있어 세부 조정 여지 존재 +- 롱프레스 인터랙션은 첫 사용자에게 즉시 인지되지 않을 수 있어 시각적 힌트 필요 가능성 있음 ## CHANGED FILES @@ -91,6 +97,9 @@ Last Updated: 2026-02-27 - `src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx` - `src/widgets/space-chrome/ui/SpaceChromeWidget.tsx` - `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 diff --git a/docs/session_brief.md b/docs/session_brief.md index 8b74d57..bae17fb 100644 --- a/docs/session_brief.md +++ b/docs/session_brief.md @@ -24,7 +24,9 @@ Last Updated: 2026-02-27 - `workFlow.md`는 토큰 절약 모드를 사용한다. - `/space` 하단 사운드 바를 제거하고 오른쪽 `🎧 Sound` 시트로 이동했다. - `/space` 헤더 프레임을 축소하고 HUD를 하단 safe-area 기준으로 더 밀착시켰다. -- 몰입 모드 ON에서 상단 우측 액션을 `나가기`로 전환했고, 클릭 시 토스트(더미)와 함께 몰입 모드 OFF 처리한다. +- 상단 우측 나가기 액션을 클릭형에서 1초 롱프레스형으로 전환했다. + - 0.05초에 진행률 20%까지 빠르게 상승하는 가속 진행을 적용했다. + - 몰입 OFF는 bar, 몰입 ON은 ring 형태로 진행률을 표시한다. - 몰입 모드 ON 시 상단 룸 블록 숨김, 레일 미니화, HUD 저대비, 비네팅 강화가 적용된다. - 이후 작업은 `docs/work.md`를 기준으로 실행한다. @@ -33,6 +35,7 @@ Last Updated: 2026-02-27 - 네트워크 제한 환경에서는 `npm run build` 시 Google Fonts fetch 실패 가능 - 터치 환경에서 레일 미니 상태가 발견성 낮을 수 있어 UX 보완이 필요할 수 있음 - safe-area가 작은 기기에서는 HUD 하단 간격 체감이 과도할 수 있어 미세 조정이 필요할 수 있음 +- 롱프레스 인터랙션은 신규 사용자에게 즉시 인지되지 않을 수 있어 보조 카피가 필요할 수 있음 ## 상세 원문 위치 diff --git a/src/features/exit-hold/index.ts b/src/features/exit-hold/index.ts new file mode 100644 index 0000000..cddd328 --- /dev/null +++ b/src/features/exit-hold/index.ts @@ -0,0 +1 @@ +export * from './ui/ExitHoldButton'; diff --git a/src/features/exit-hold/model/useHoldToConfirm.ts b/src/features/exit-hold/model/useHoldToConfirm.ts new file mode 100644 index 0000000..7a3bd61 --- /dev/null +++ b/src/features/exit-hold/model/useHoldToConfirm.ts @@ -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(null); + const startRef = useRef(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, + }; +}; diff --git a/src/features/exit-hold/ui/ExitHoldButton.tsx b/src/features/exit-hold/ui/ExitHoldButton.tsx new file mode 100644 index 0000000..0e67015 --- /dev/null +++ b/src/features/exit-hold/ui/ExitHoldButton.tsx @@ -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) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + start(); + } + }; + + const handleKeyUp = (event: KeyboardEvent) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + cancel(); + } + }; + + if (variant === 'ring') { + return ( + + ); + } + + return ( + + ); +}; diff --git a/src/widgets/space-chrome/ui/SpaceChromeWidget.tsx b/src/widgets/space-chrome/ui/SpaceChromeWidget.tsx index 18e8803..443ab66 100644 --- a/src/widgets/space-chrome/ui/SpaceChromeWidget.tsx +++ b/src/widgets/space-chrome/ui/SpaceChromeWidget.tsx @@ -1,17 +1,17 @@ -import Link from 'next/link'; +import { ExitHoldButton } from '@/features/exit-hold'; interface SpaceChromeWidgetProps { roomName: string; vibeLabel: string; isImmersionMode: boolean; - onExitImmersionMode: () => void; + onExitRequested: () => void; } export const SpaceChromeWidget = ({ roomName, vibeLabel, isImmersionMode, - onExitImmersionMode, + onExitRequested, }: SpaceChromeWidgetProps) => { return (
@@ -23,23 +23,10 @@ export const SpaceChromeWidget = ({

VibeRoom

- {isImmersionMode ? ( - - ) : ( - - 허브로 돌아가기 - - )} + {!isImmersionMode ? ( diff --git a/src/widgets/space-shell/ui/SpaceSkeletonWidget.tsx b/src/widgets/space-shell/ui/SpaceSkeletonWidget.tsx index cafb13e..76813de 100644 --- a/src/widgets/space-shell/ui/SpaceSkeletonWidget.tsx +++ b/src/widgets/space-shell/ui/SpaceSkeletonWidget.tsx @@ -66,7 +66,7 @@ export const SpaceSkeletonWidget = () => { roomName={room.name} vibeLabel={room.vibeLabel} isImmersionMode={isImmersionMode} - onExitImmersionMode={exitImmersionMode} + onExitRequested={exitImmersionMode} />