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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user