feat(space/app): app 진입부 및 space 몰입 환경(HUD/Tools) 프리미엄 UI 리팩토링

맥락:
- 기존 app 대시보드와 space 화면의 UI가 SaaS 툴처럼 딱딱하고 투박하여, 유저가 기꺼이 지갑을 열 만한 몰입감과 고급스러움(Premium feel)이 부족함.
- 인지적 과부하를 줄이기 위해 제안된 '첫 5분 행동(Micro-step)'이 타이머 영역에 묻혀 있어 행동 유발 효과가 미미함.

변경사항:
- app: 컨테이너 박스를 제거하고 전체 배경 화면(Immersive Background)과 Glassmorphism을 활용한 1.5 Step 진입 플로우로 전면 개편.
- space/hud: 하단의 두꺼운 타이머 패널을 초박형(Slim) 글라스 알약 형태로 축소하여 배경 씬의 개방감 확보.
- space/hud: 목표(Goal)와 첫 단계(Micro-step)를 분리하여 좌측 상단의 우아한 Floating UI로 재배치하고, 체크 완료 시 사라지는 도파민 인터랙션 추가.
- space/tools: 흩어져 있던 노트, 사운드, 설정 도구들을 우측 레일(Right-Rail)로 통합하고 팝오버 디자인을 고급화함.
- ui/contrast: 밝은 배경에서도 텍스트가 잘 보이도록 좌측 상단 비네팅(Vignette) 및 다중 텍스트 그림자(Multi-layered Shadow) 효과 적용.

검증:
- npm run build 정상 통과 확인.
- 브라우저 상에서 micro-step 완료 애니메이션 및 도구막대 팝오버 슬라이드 동작 확인.

세션-상태: app 진입부터 space 몰입까지의 코어 UX/UI 하이엔드 개편 완료.
세션-다음: 프로 요금제(PRO) 전환 유도(Paywall) 흐름 및 상세 분석 리포트(Analytics) 뷰 구현.
세션-리스크: 없음.
This commit is contained in:
2026-03-13 14:57:35 +09:00
parent 2506dd53a7
commit abdde2a8ae
36 changed files with 2120 additions and 923 deletions

View File

@@ -10,7 +10,6 @@ import {
interface SpaceTimerHudWidgetProps {
timerLabel: string;
goal: string;
timeDisplay?: string;
className?: string;
hasActiveSession?: boolean;
@@ -24,14 +23,12 @@ interface SpaceTimerHudWidgetProps {
onStartClick?: () => void;
onPauseClick?: () => void;
onResetClick?: () => void;
onGoalCompleteRequest?: () => void;
}
const HUD_ACTIONS = copy.space.timerHud.actions;
export const SpaceTimerHudWidget = ({
timerLabel,
goal,
timeDisplay = '25:00',
className,
hasActiveSession = false,
@@ -45,10 +42,8 @@ export const SpaceTimerHudWidget = ({
onStartClick,
onPauseClick,
onResetClick,
onGoalCompleteRequest,
}: SpaceTimerHudWidgetProps) => {
const { isBreatheMode, triggerRestart } = useRestart30s();
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.timerHud.goalFallback;
const modeLabel = isBreatheMode
? RECOVERY_30S_MODE_LABEL
: !hasActiveSession
@@ -60,120 +55,85 @@ export const SpaceTimerHudWidget = ({
return (
<div
className={cn(
'pointer-events-none fixed inset-x-0 z-20 px-4 pr-16 sm:px-6',
'pointer-events-none fixed inset-x-0 z-20 flex justify-center px-4 sm:px-6',
className,
)}
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 0.35rem)' }}
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 2rem)' }}
>
<div className="relative mx-auto w-full max-w-xl pointer-events-auto">
<div
aria-hidden
className="pointer-events-none absolute left-1/2 top-1/2 z-0 h-28 w-[min(760px,96vw)] -translate-x-1/2 -translate-y-1/2 bg-[radial-gradient(ellipse_at_center,rgba(2,6,23,0.2)_0%,rgba(2,6,23,0.12)_45%,rgba(2,6,23,0)_78%)]"
/>
<div className="relative pointer-events-auto">
<section
className={cn(
'relative z-10 flex h-[4.85rem] items-center justify-between gap-3 overflow-hidden rounded-2xl px-3.5 py-2 transition-colors',
'relative z-10 flex h-[3.5rem] items-center justify-between gap-6 overflow-hidden rounded-full px-5 transition-colors',
isImmersionMode
? 'border border-white/12 bg-black/22 backdrop-blur-md'
: 'border border-white/12 bg-black/24 backdrop-blur-md',
? 'border border-white/10 bg-black/20 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.12)]'
: 'border border-white/15 bg-black/30 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.16)]',
)}
>
<div className="min-w-0">
<div className="flex items-baseline gap-2">
<span
className={cn(
'text-[11px] font-semibold uppercase tracking-[0.16em]',
isImmersionMode ? 'text-white/90' : 'text-white/88',
)}
>
{modeLabel}
</span>
<span
className={cn(
'text-[1.7rem] font-semibold tracking-tight sm:text-[1.78rem]',
isImmersionMode ? 'text-white/90' : 'text-white/92',
)}
>
{timeDisplay}
</span>
<span className={cn('text-[11px]', isImmersionMode ? 'text-white/65' : 'text-white/65')}>
{timerLabel}
</span>
</div>
<div className="mt-1.5 flex min-w-0 items-center gap-2">
<p className={cn('min-w-0 truncate text-sm', isImmersionMode ? 'text-white/88' : 'text-white/86')}>
<span className="text-white/62">{copy.space.timerHud.goalPrefix}</span>
<span className="text-white/90">{normalizedGoal}</span>
</p>
<button
type="button"
onClick={onGoalCompleteRequest}
className="shrink-0 rounded-full border border-white/16 bg-white/[0.04] px-2 py-0.5 text-[10px] text-white/70 transition-colors hover:bg-white/[0.1] hover:text-white/86"
>
{copy.space.timerHud.completeButton}
</button>
</div>
<div className="flex items-center gap-3">
<span
className={cn(
'w-14 text-right text-[10px] font-bold uppercase tracking-[0.15em] opacity-80',
sessionPhase === 'break' ? 'text-emerald-400' : 'text-brand-primary'
)}
>
{modeLabel}
</span>
<span className="w-[1px] h-4 bg-white/10" />
<span
className={cn(
'w-20 text-[1.4rem] font-medium tracking-tight text-center',
isImmersionMode ? 'text-white/90' : 'text-white',
)}
>
{timeDisplay}
</span>
</div>
<div className="flex items-center gap-2.5">
<div className="flex items-center gap-1.5">
{HUD_ACTIONS.map((action) => {
const isStartAction = action.id === 'start';
const isPauseAction = action.id === 'pause';
const isResetAction = action.id === 'reset';
const isDisabled =
isControlsDisabled ||
(isStartAction ? !canStart : isPauseAction ? !canPause : !canReset);
const isHighlighted =
(isStartAction && playbackState !== 'running') ||
(isPauseAction && playbackState === 'running');
<div className="flex items-center gap-1.5 pl-2 border-l border-white/10">
{HUD_ACTIONS.map((action) => {
const isStartAction = action.id === 'start';
const isPauseAction = action.id === 'pause';
const isResetAction = action.id === 'reset';
const isDisabled =
isControlsDisabled ||
(isStartAction ? !canStart : isPauseAction ? !canPause : !canReset);
const isHighlighted =
(isStartAction && playbackState !== 'running') ||
(isPauseAction && playbackState === 'running');
return (
<button
key={action.id}
type="button"
title={action.label}
aria-pressed={isHighlighted}
disabled={isDisabled}
onClick={() => {
if (isStartAction) {
onStartClick?.();
}
if (isPauseAction) {
onPauseClick?.();
}
if (isResetAction) {
onResetClick?.();
}
}}
className={cn(
'inline-flex h-9 w-9 items-center justify-center rounded-full border text-sm transition-[transform,background-color,border-color,box-shadow,color,opacity] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80 active:translate-y-px active:scale-[0.95] disabled:cursor-not-allowed disabled:opacity-38 disabled:shadow-none',
'shadow-[inset_0_1px_0_rgba(255,255,255,0.08),0_8px_18px_rgba(2,6,23,0.18)]',
isImmersionMode
? 'border-white/14 bg-black/28 text-white/82 hover:border-white/22 hover:bg-white/[0.09]'
: 'border-white/14 bg-black/28 text-white/84 hover:border-white/22 hover:bg-white/[0.09]',
isStartAction && isHighlighted
? 'border-sky-200/56 bg-sky-200/20 text-sky-50 shadow-[inset_0_1px_0_rgba(255,255,255,0.12),0_10px_22px_rgba(56,189,248,0.24)]'
: '',
isPauseAction && isHighlighted
? 'border-amber-200/52 bg-amber-200/18 text-amber-50 shadow-[inset_0_1px_0_rgba(255,255,255,0.12),0_10px_22px_rgba(251,191,36,0.18)]'
: '',
isResetAction && !isDisabled
? 'hover:border-white/26 hover:bg-white/[0.12] hover:text-white'
: '',
)}
>
<span aria-hidden>{action.icon}</span>
<span className="sr-only">{action.label}</span>
</button>
);
})}
</div>
return (
<button
key={action.id}
type="button"
title={action.label}
aria-pressed={isHighlighted}
disabled={isDisabled}
onClick={() => {
if (isStartAction) onStartClick?.();
if (isPauseAction) onPauseClick?.();
if (isResetAction) onResetClick?.();
}}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-full text-sm transition-all duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 active:scale-95 disabled:cursor-not-allowed disabled:opacity-30',
isImmersionMode
? 'text-white/70 hover:bg-white/10 hover:text-white'
: 'text-white/80 hover:bg-white/15 hover:text-white',
isStartAction && isHighlighted
? 'bg-white/10 text-white shadow-sm'
: '',
isPauseAction && isHighlighted
? 'bg-white/10 text-white shadow-sm'
: '',
)}
>
<span aria-hidden>{action.icon}</span>
<span className="sr-only">{action.label}</span>
</button>
);
})}
<Restart30sAction
onTrigger={triggerRestart}
className={cn(isImmersionMode ? 'text-white/72 hover:text-white/92' : 'text-white/74 hover:text-white/92')}
className="h-8 w-8 ml-1"
/>
</div>
</section>