style(space): 리추얼 진입 UX와 포커스 전환 흐름을 고급화

맥락:
- /space 진입 경험이 설정 패널처럼 보여 몰입형 라운지 톤이 약했습니다.
- 목표 입력 후 시작 전환 동선을 더 빠르고 일관되게 만들 필요가 있었습니다.

변경사항:
- 도크 아이콘을 이모지에서 단일 라인 SVG 세트로 통일해 시각 언어 일관성을 맞췄습니다.
- Setup Drawer 밀도를 낮추고(타이포/테두리/칩 크기) 3-step 리추얼 흐름을 더 간결하게 정리했습니다.
- 목표 입력 필드 자동 포커스를 추가해 진입 즉시 타이핑이 가능하도록 했습니다.
- 시작 버튼을 form submit으로 연결해 Enter 입력과 버튼 클릭이 동일하게 동작하도록 변경했습니다.
- SpaceSideSheet에 300ms 닫힘 전환(오버레이/시트 opacity+translate) 애니메이션을 적용했습니다.
- Focus 진입 토스트 카피를 목표 중심 문구로 바꾸고 Setup 선택지를 최소 개수로 제한했습니다.
- 배경에 미세 stage-pan/light-drift 키프레임을 추가해 정적인 평면감을 줄였습니다.

검증:
- npx tsc --noEmit
- npm run build

세션-상태: /space에서 목표 입력 후 10초 내 Focus 전환 가능한 리추얼 흐름이 정리되었습니다.
세션-다음: 실제 브라우저에서 애니메이션 강도와 드로어 밀도 체감 QA를 진행합니다.
세션-리스크: 저사양 환경에서 배경 미세 모션이 과하게 느껴질 수 있어 추후 reduce-motion 강화를 검토할 수 있습니다.
This commit is contained in:
2026-03-03 14:27:14 +09:00
parent c6082a09d7
commit ef9cc63cc5
6 changed files with 226 additions and 83 deletions

View File

@@ -1,8 +1,10 @@
'use client';
import { useEffect, type ReactNode } from 'react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { cn } from '@/shared/lib/cn';
const SHEET_TRANSITION_MS = 300;
interface SpaceSideSheetProps {
open: boolean;
title: string;
@@ -24,6 +26,41 @@ export const SpaceSideSheet = ({
widthClassName,
dismissible = true,
}: SpaceSideSheetProps) => {
const closeTimerRef = useRef<number | null>(null);
const [shouldRender, setShouldRender] = useState(open);
const [visible, setVisible] = useState(open);
useEffect(() => {
if (closeTimerRef.current) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
if (open) {
setShouldRender(true);
const raf = window.requestAnimationFrame(() => {
setVisible(true);
});
return () => {
window.cancelAnimationFrame(raf);
};
}
setVisible(false);
closeTimerRef.current = window.setTimeout(() => {
setShouldRender(false);
closeTimerRef.current = null;
}, SHEET_TRANSITION_MS);
return () => {
if (closeTimerRef.current) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
};
}, [open]);
useEffect(() => {
if (!open) {
return;
@@ -42,7 +79,7 @@ export const SpaceSideSheet = ({
};
}, [open, onClose]);
if (!open) {
if (!shouldRender) {
return null;
}
@@ -53,39 +90,54 @@ export const SpaceSideSheet = ({
type="button"
aria-label="시트 닫기"
onClick={onClose}
className="fixed inset-0 z-40 bg-slate-950/18 backdrop-blur-[1px]"
className={cn(
'fixed inset-0 z-40 bg-slate-950/14 backdrop-blur-[1px] transition-opacity duration-300',
visible ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
/>
) : (
<div aria-hidden className="fixed inset-0 z-40 bg-slate-950/12 backdrop-blur-[1px]" />
<div
aria-hidden
className={cn(
'fixed inset-0 z-40 bg-slate-950/8 backdrop-blur-[1px] transition-opacity duration-300',
visible ? 'opacity-100' : 'opacity-0',
)}
/>
)}
<aside
className={cn(
'fixed inset-y-0 right-0 z-50 p-2 sm:p-3',
'fixed inset-y-0 right-0 z-50 p-2 transition-opacity duration-300 sm:p-3',
visible ? 'opacity-100' : 'pointer-events-none opacity-0',
widthClassName ?? 'w-[min(360px,92vw)]',
)}
>
<div className="flex h-full flex-col overflow-hidden rounded-3xl border border-white/20 bg-slate-950/58 text-white shadow-[0_20px_60px_rgba(2,6,23,0.42)] backdrop-blur-2xl">
<header className="flex items-start justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5">
<div
className={cn(
'flex h-full flex-col overflow-hidden rounded-3xl border border-white/14 bg-[linear-gradient(180deg,rgba(15,23,42,0.74)_0%,rgba(10,15,30,0.62)_100%)] text-white shadow-[0_18px_52px_rgba(2,6,23,0.34)] backdrop-blur-2xl transition-transform duration-300',
visible ? 'translate-x-0' : 'translate-x-8',
)}
>
<header className="flex items-start justify-between gap-3 border-b border-white/7 px-4 py-3 sm:px-5">
<div>
<h2 className="text-base font-semibold text-white">{title}</h2>
{subtitle ? <p className="mt-1 text-xs text-white/58">{subtitle}</p> : null}
<h2 className="text-[1.05rem] font-semibold tracking-tight text-white">{title}</h2>
{subtitle ? <p className="mt-1 text-[11px] text-white/56">{subtitle}</p> : null}
</div>
{dismissible ? (
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/18 bg-white/8 text-sm text-white/80 transition-colors hover:bg-white/14 hover:text-white"
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/14 bg-white/6 text-[12px] text-white/72 transition-colors hover:bg-white/12 hover:text-white"
>
</button>
) : null}
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5">{children}</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3.5 sm:px-5">{children}</div>
{footer ? <footer className="border-t border-white/10 bg-white/4 px-4 py-3 sm:px-5">{footer}</footer> : null}
{footer ? <footer className="border-t border-white/8 bg-white/[0.02] px-4 py-3 sm:px-5">{footer}</footer> : null}
</div>
</aside>
</>