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

@@ -1,11 +1,11 @@
'use client';
import { useCallback, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { usePlanTier } from '@/entities/plan';
import type { RecentThought } from '@/entities/session';
import { copy } from '@/shared/i18n';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from './types';
import type { PlanTier } from '@/entities/plan';
interface UseSpaceToolsDockHandlersParams {
setIdle: (idle: boolean) => void;
@@ -44,7 +44,7 @@ export const useSpaceToolsDockHandlers = ({
}: UseSpaceToolsDockHandlersParams) => {
const { toolsDock } = copy.space;
const [noteDraft, setNoteDraft] = useState('');
const [plan, setPlan] = useState<PlanTier>('normal');
const { plan, setPlan } = usePlanTier();
const openUtilityPanel = useCallback((panel: SpaceUtilityPanelId) => {
setIdle(false);
@@ -148,11 +148,11 @@ export const useSpaceToolsDockHandlers = ({
const handleSelectProFeature = useCallback((featureId: string) => {
const label =
featureId === 'scene-packs'
? toolsDock.featureLabels.scenePacks
: featureId === 'sound-packs'
? toolsDock.featureLabels.soundPacks
: toolsDock.featureLabels.profiles;
featureId === 'daily-plan'
? toolsDock.featureLabels.dailyPlan
: featureId === 'rituals'
? toolsDock.featureLabels.rituals
: toolsDock.featureLabels.weeklyReview;
onStatusMessage({ message: toolsDock.proFeaturePending(label) });
}, [onStatusMessage, toolsDock.featureLabels]);

View File

@@ -79,82 +79,32 @@ export const FocusModeAnchors = ({
type="button"
aria-label={copy.space.toolsDock.popoverCloseAria}
onClick={onClosePopover}
className="fixed inset-0 z-30"
className="fixed inset-0 z-30 cursor-default"
/>
) : null}
<FocusRightRail
isIdle={isIdle}
thoughtCount={thoughtCount}
openPopover={openPopover}
noteDraft={noteDraft}
selectedSoundLabel={selectedSoundLabel}
isSoundMuted={isSoundMuted}
soundVolume={soundVolume}
volumeFeedback={volumeFeedback}
quickSoundPresets={quickSoundPresets}
selectedPresetId={selectedPresetId}
onOpenInbox={onOpenInbox}
onOpenControlCenter={onOpenControlCenter}
onToggleNotes={onToggleNotes}
onToggleSound={onToggleSound}
onNoteDraftChange={onNoteDraftChange}
onNoteSubmit={onNoteSubmit}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onVolumeKeyDown={onVolumeKeyDown}
onSelectPreset={onSelectPreset}
/>
<div
className={cn(
anchorContainerClassName,
'left-[calc(env(safe-area-inset-left,0px)+0.75rem)]',
isIdle ? 'opacity-34' : 'opacity-82',
)}
>
<div className="relative">
<div aria-hidden className={anchorHaloClassName} />
<button
type="button"
onClick={onToggleNotes}
className={anchorButtonClassName}
>
<span aria-hidden className="text-white/82">{ANCHOR_ICON.notes}</span>
<span>{copy.space.toolsDock.notesButton} {formatThoughtCount(thoughtCount)}</span>
<span aria-hidden className="text-white/60"></span>
</button>
{openPopover === 'notes' ? (
<QuickNotesPopover
noteDraft={noteDraft}
onDraftChange={onNoteDraftChange}
onDraftEnter={onNoteSubmit}
onSubmit={onNoteSubmit}
/>
) : null}
</div>
</div>
<div
className={cn(
anchorContainerClassName,
'right-[calc(env(safe-area-inset-right,0px)+0.75rem)]',
isIdle ? 'opacity-34' : 'opacity-82',
)}
>
<div className="relative">
<div aria-hidden className={anchorHaloClassName} />
<button
type="button"
onClick={onToggleSound}
className={anchorButtonClassName}
>
<span aria-hidden className="text-white/82">{ANCHOR_ICON.sound}</span>
<span className="max-w-[132px] truncate">{selectedSoundLabel}</span>
<span aria-hidden className="text-white/60"></span>
</button>
{openPopover === 'sound' ? (
<QuickSoundPopover
selectedSoundLabel={selectedSoundLabel}
isSoundMuted={isSoundMuted}
soundVolume={soundVolume}
volumeFeedback={volumeFeedback}
quickSoundPresets={quickSoundPresets}
selectedPresetId={selectedPresetId}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onVolumeKeyDown={onVolumeKeyDown}
onSelectPreset={onSelectPreset}
/>
) : null}
</div>
</div>
</>
);
};

View File

@@ -1,53 +1,179 @@
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
import type { SoundPreset } from '@/entities/session';
import { cn } from '@/shared/lib/cn';
import { copy } from '@/shared/i18n';
import { formatThoughtCount, RAIL_ICON } from './constants';
import type { SpaceAnchorPopoverId } from '../model/types';
import { formatThoughtCount, RAIL_ICON, ANCHOR_ICON } from './constants';
import { QuickNotesPopover } from './popovers/QuickNotesPopover';
import { QuickSoundPopover } from './popovers/QuickSoundPopover';
interface FocusRightRailProps {
isIdle: boolean;
thoughtCount: number;
openPopover: SpaceAnchorPopoverId | null;
noteDraft: string;
selectedSoundLabel: string;
isSoundMuted: boolean;
soundVolume: number;
volumeFeedback: string | null;
quickSoundPresets: SoundPreset[];
selectedPresetId: string;
onOpenInbox: () => void;
onOpenControlCenter: () => void;
onToggleNotes: () => void;
onToggleSound: () => void;
onNoteDraftChange: (value: string) => void;
onNoteSubmit: () => void;
onToggleMute: () => void;
onVolumeChange: (nextVolume: number) => void;
onVolumeKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void;
onSelectPreset: (presetId: string) => void;
}
export const FocusRightRail = ({
isIdle,
thoughtCount,
openPopover,
noteDraft,
selectedSoundLabel,
isSoundMuted,
soundVolume,
volumeFeedback,
quickSoundPresets,
selectedPresetId,
onOpenInbox,
onOpenControlCenter,
onToggleNotes,
onToggleSound,
onNoteDraftChange,
onNoteSubmit,
onToggleMute,
onVolumeChange,
onVolumeKeyDown,
onSelectPreset,
}: FocusRightRailProps) => {
return (
<div
className={cn(
'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] top-1/2 -translate-y-1/2',
isIdle ? 'opacity-34' : 'opacity-78',
'fixed z-30 transition-all duration-500 right-[calc(env(safe-area-inset-right,0px)+1.5rem)] top-1/2 -translate-y-1/2',
isIdle ? 'opacity-0 translate-x-4 pointer-events-none' : 'opacity-100 translate-x-0',
)}
>
<div className="rounded-2xl border border-white/14 bg-black/22 p-1.5 backdrop-blur-md">
<div className="flex flex-col gap-1">
<div className="relative flex flex-col gap-2 rounded-full border border-white/10 bg-black/20 p-2.5 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.2)]">
{/* Notes Toggle */}
<div className="relative group">
<button
type="button"
aria-label={copy.space.toolsDock.notesButton}
onClick={onToggleNotes}
className={cn(
"relative inline-flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200",
openPopover === 'notes' ? "bg-white/20 text-white shadow-inner" : "bg-transparent text-white/70 hover:bg-white/10 hover:text-white"
)}
>
{ANCHOR_ICON.notes}
</button>
{/* Tooltip */}
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
{copy.space.toolsDock.notesButton}
</span>
</div>
{openPopover === 'notes' ? (
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-4">
<QuickNotesPopover
noteDraft={noteDraft}
onDraftChange={onNoteDraftChange}
onDraftEnter={onNoteSubmit}
onSubmit={onNoteSubmit}
/>
</div>
) : null}
</div>
{/* Sound Toggle */}
<div className="relative group">
<button
type="button"
aria-label="사운드"
onClick={onToggleSound}
className={cn(
"relative inline-flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200",
openPopover === 'sound' ? "bg-white/20 text-white shadow-inner" : "bg-transparent text-white/70 hover:bg-white/10 hover:text-white"
)}
>
{ANCHOR_ICON.sound}
</button>
{/* Tooltip */}
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
</span>
</div>
{openPopover === 'sound' ? (
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-4">
<QuickSoundPopover
selectedSoundLabel={selectedSoundLabel}
isSoundMuted={isSoundMuted}
soundVolume={soundVolume}
volumeFeedback={volumeFeedback}
quickSoundPresets={quickSoundPresets}
selectedPresetId={selectedPresetId}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onVolumeKeyDown={onVolumeKeyDown}
onSelectPreset={onSelectPreset}
/>
</div>
) : null}
</div>
<div className="w-6 h-px bg-white/10 mx-auto my-1" />
{/* Inbox Button */}
<div className="relative group">
<button
type="button"
aria-label={copy.space.inbox.openInboxAriaLabel}
title={copy.space.inbox.openInboxTitle}
onClick={onOpenInbox}
className="relative inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
className="relative inline-flex h-10 w-10 items-center justify-center rounded-full bg-transparent text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
{RAIL_ICON.inbox}
{thoughtCount > 0 ? (
<span className="absolute -right-1 -top-1 inline-flex min-w-[0.95rem] items-center justify-center rounded-full bg-sky-200/28 px-1 py-0.5 text-[8px] font-semibold text-sky-50">
<span className="absolute 0 top-0 right-0 inline-flex min-w-[1rem] items-center justify-center rounded-full bg-brand-primary px-1 py-0.5 text-[9px] font-bold text-white shadow-sm ring-2 ring-black/20">
{formatThoughtCount(thoughtCount)}
</span>
) : null}
</button>
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
{copy.space.inbox.openInboxTitle}
</span>
</div>
</div>
{/* Control Center Button */}
<div className="relative group">
<button
type="button"
aria-label={copy.space.rightRail.openQuickControlsAriaLabel}
title={copy.space.rightRail.openQuickControlsTitle}
onClick={onOpenControlCenter}
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-transparent text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
{RAIL_ICON.controlCenter}
</button>
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
{copy.space.rightRail.openQuickControlsTitle}
</span>
</div>
</div>
</div>
</div>
);

View File

@@ -15,11 +15,10 @@ export const QuickNotesPopover = ({
}: QuickNotesPopoverProps) => {
return (
<div
className="mb-2 w-[min(320px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', left: 0 }}
className="mb-2 w-[320px] rounded-[1.5rem] border border-white/10 bg-black/30 p-5 shadow-[0_24px_60px_rgba(0,0,0,0.4)] backdrop-blur-2xl animate-in fade-in zoom-in-95 slide-in-from-right-4 duration-300 origin-right"
>
<p className="text-[11px] text-white/56">{copy.space.quickNotes.title}</p>
<div className="mt-2 flex gap-1.5">
<p className="text-[11px] font-medium uppercase tracking-widest text-white/50">{copy.space.quickNotes.title}</p>
<div className="mt-4 flex flex-col gap-3">
<input
value={noteDraft}
onChange={(event) => onDraftChange(event.target.value)}
@@ -32,17 +31,21 @@ export const QuickNotesPopover = ({
onDraftEnter();
}}
placeholder={copy.space.quickNotes.placeholder}
className="h-8 min-w-0 flex-1 rounded-lg border border-white/14 bg-white/[0.04] px-2.5 text-xs text-white placeholder:text-white/38 focus:border-sky-200/42 focus:outline-none"
autoFocus
className="w-full border-b border-white/20 bg-transparent pb-2 text-sm text-white placeholder:text-white/30 transition-colors focus:border-white/60 focus:outline-none"
/>
<button
type="button"
onClick={onSubmit}
className="h-8 rounded-lg border border-sky-200/34 bg-sky-200/14 px-2.5 text-xs text-white/88"
>
{copy.space.quickNotes.submit}
</button>
<div className="flex items-center justify-between">
<p className="text-[10px] text-white/40">{copy.space.quickNotes.hint}</p>
<button
type="button"
onClick={onSubmit}
disabled={!noteDraft.trim()}
className="rounded-full bg-white/10 px-4 py-1.5 text-xs font-medium text-white transition-all hover:bg-white/20 active:scale-95 disabled:opacity-30"
>
{copy.space.quickNotes.submit}
</button>
</div>
</div>
<p className="mt-2 text-[11px] text-white/52">{copy.space.quickNotes.hint}</p>
</div>
);
};

View File

@@ -30,60 +30,71 @@ export const QuickSoundPopover = ({
}: QuickSoundPopoverProps) => {
return (
<div
className="mb-2 w-[min(288px,calc(100vw-2rem))] rounded-2xl border border-white/14 bg-slate-950/74 p-3 shadow-[0_18px_44px_rgba(2,6,23,0.4)] backdrop-blur-xl animate-[popover-rise_220ms_ease-out] motion-reduce:animate-none"
style={{ position: 'absolute', bottom: 'calc(100% + 0.5rem)', right: 0 }}
className="mb-2 w-[320px] rounded-[1.5rem] border border-white/10 bg-black/30 p-5 shadow-[0_24px_60px_rgba(0,0,0,0.4)] backdrop-blur-2xl animate-in fade-in zoom-in-95 slide-in-from-right-4 duration-300 origin-right"
>
<p className="text-[11px] text-white/56">{copy.space.quickSound.currentSound}</p>
<p className="mt-1 truncate text-sm font-medium text-white/88">{selectedSoundLabel}</p>
<div className="flex items-center justify-between">
<p className="text-[11px] font-medium uppercase tracking-widest text-white/50">{copy.space.quickSound.currentSound}</p>
<span className="text-[11px] font-medium text-white/90 bg-white/10 px-2 py-0.5 rounded-md">
{volumeFeedback ?? (isSoundMuted ? '0%' : `${soundVolume}%`)}
</span>
</div>
<p className="mt-2 truncate text-base font-medium text-white/90">{selectedSoundLabel}</p>
<div className="mt-3 rounded-xl border border-white/14 bg-white/[0.04] px-2.5 py-2">
<div className="flex items-center gap-2">
<div className="mt-5 rounded-2xl border border-white/10 bg-white/5 p-3 backdrop-blur-md">
<div className="flex items-center gap-3">
<button
type="button"
aria-label={isSoundMuted ? copy.space.quickSound.unmuteAriaLabel : copy.space.quickSound.muteAriaLabel}
onClick={onToggleMute}
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/16 bg-white/[0.05] text-xs text-white/80 transition-colors hover:bg-white/[0.12]"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/10 text-[13px] text-white/80 transition-all hover:bg-white/20 active:scale-95"
>
🔇
{isSoundMuted ? '🔇' : '🔊'}
</button>
<input
type="range"
min={0}
max={100}
step={1}
value={soundVolume}
onChange={(event) => onVolumeChange(Number(event.target.value))}
onKeyDown={onVolumeKeyDown}
aria-label={copy.space.quickSound.volumeAriaLabel}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-white/18 accent-sky-200"
/>
<span className="w-9 text-right text-[11px] text-white/66">
{volumeFeedback ?? (isSoundMuted ? '0%' : `${soundVolume}%`)}
</span>
<div className="relative flex w-full items-center">
<input
type="range"
min={0}
max={100}
step={1}
value={soundVolume}
onChange={(event) => onVolumeChange(Number(event.target.value))}
onKeyDown={onVolumeKeyDown}
aria-label={copy.space.quickSound.volumeAriaLabel}
className="absolute z-10 w-full cursor-pointer appearance-none bg-transparent accent-white outline-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md"
/>
<div className="h-1.5 w-full rounded-full bg-white/10 overflow-hidden">
<div
className="h-full bg-white/90 transition-all duration-150 ease-out"
style={{ width: `${soundVolume}%` }}
/>
</div>
</div>
</div>
</div>
<p className="mt-3 text-[11px] text-white/56">{copy.space.quickSound.quickSwitch}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{quickSoundPresets.map((preset) => {
const selected = preset.id === selectedPresetId;
<div className="mt-6">
<p className="text-[10px] font-medium uppercase tracking-widest text-white/40 mb-3">{copy.space.quickSound.quickSwitch}</p>
<div className="flex flex-wrap gap-2">
{quickSoundPresets.map((preset) => {
const selected = preset.id === selectedPresetId;
return (
<button
key={preset.id}
type="button"
onClick={() => onSelectPreset(preset.id)}
className={cn(
'rounded-full border px-2.5 py-0.5 text-[10px] transition-colors',
selected
? 'border-sky-200/34 bg-sky-200/14 text-white/90'
: 'border-white/12 bg-white/[0.03] text-white/66 hover:bg-white/8',
)}
>
{preset.label}
</button>
);
})}
return (
<button
key={preset.id}
type="button"
onClick={() => onSelectPreset(preset.id)}
className={cn(
'rounded-full border px-3 py-1.5 text-[11px] font-medium transition-all active:scale-95',
selected
? 'border-white/40 bg-white/20 text-white shadow-sm'
: 'border-transparent bg-white/5 text-white/60 hover:bg-white/15 hover:text-white',
)}
>
{preset.label}
</button>
);
})}
</div>
</div>
</div>
);