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

@@ -28,6 +28,7 @@ import { useWorkspaceMediaDiagnostics } from './useWorkspaceMediaDiagnostics';
interface UseSpaceWorkspaceSelectionParams {
initialSceneId: string;
initialGoal: string;
initialFocusPlanItemId: string | null;
initialTimerLabel: string;
sceneQuery: string | null;
goalQuery: string;
@@ -64,6 +65,7 @@ const getVisibleSetupScenes = (selectedScene: SceneTheme) => {
export const useSpaceWorkspaceSelection = ({
initialSceneId,
initialGoal,
initialFocusPlanItemId,
initialTimerLabel,
sceneQuery,
goalQuery,
@@ -86,6 +88,7 @@ export const useSpaceWorkspaceSelection = ({
const [selectedSceneId, setSelectedSceneId] = useState(initialSceneId);
const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel);
const [goalInput, setGoalInput] = useState(initialGoal);
const [linkedFocusPlanItemId, setLinkedFocusPlanItemId] = useState<string | null>(initialFocusPlanItemId);
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
const [resumeGoal, setResumeGoal] = useState('');
const [showResumePrompt, setShowResumePrompt] = useState(false);
@@ -262,6 +265,7 @@ export const useSpaceWorkspaceSelection = ({
const handleGoalChipSelect = useCallback((chip: GoalChip) => {
setShowResumePrompt(false);
setLinkedFocusPlanItemId(null);
setSelectedGoalId(chip.id);
setGoalInput(chip.label);
}, []);
@@ -271,6 +275,7 @@ export const useSpaceWorkspaceSelection = ({
setShowResumePrompt(false);
}
setLinkedFocusPlanItemId(null);
setGoalInput(value);
if (value.trim().length === 0) {
@@ -385,6 +390,7 @@ export const useSpaceWorkspaceSelection = ({
setSelectedTimerLabel(nextTimerLabel);
setSelectedPresetId(nextSoundPresetId);
setGoalInput(currentSession.goal);
setLinkedFocusPlanItemId(currentSession.focusPlanItemId ?? null);
setSelectedGoalId(null);
setShowResumePrompt(false);
});
@@ -418,6 +424,7 @@ export const useSpaceWorkspaceSelection = ({
selectedSceneId,
selectedTimerLabel,
goalInput,
linkedFocusPlanItemId,
selectedGoalId,
resumeGoal,
showResumePrompt,
@@ -428,6 +435,7 @@ export const useSpaceWorkspaceSelection = ({
setupScenes,
canStart,
setGoalInput,
setLinkedFocusPlanItemId,
setSelectedGoalId,
setShowResumePrompt,
setResumeGoal,

View File

@@ -17,6 +17,7 @@ interface UseSpaceWorkspaceSessionControlsParams {
canStart: boolean;
currentSession: FocusSession | null;
goalInput: string;
linkedFocusPlanItemId: string | null;
selectedSceneId: string;
selectedTimerLabel: string;
selectedPresetId: string;
@@ -28,18 +29,24 @@ interface UseSpaceWorkspaceSessionControlsParams {
sceneId: string;
goal: string;
timerPresetId: string;
soundPresetId: string;
soundPresetId: string | null;
focusPlanItemId?: string;
entryPoint: SessionEntryPoint;
}) => Promise<FocusSession | null>;
pauseSession: () => Promise<FocusSession | null>;
resumeSession: () => Promise<FocusSession | null>;
restartCurrentPhase: () => Promise<FocusSession | null>;
completeSession: (input: {
completionType: 'goal-complete';
advanceGoal: (input: {
completedGoal: string;
}) => Promise<FocusSession | null>;
nextGoal: string;
sceneId: string;
timerPresetId: string;
soundPresetId: string;
focusPlanItemId?: string;
}) => Promise<{ nextSession: FocusSession } | null>;
abandonSession: () => Promise<boolean>;
setGoalInput: (value: string) => void;
setLinkedFocusPlanItemId: (value: string | null) => void;
setSelectedGoalId: (value: string | null) => void;
setShowResumePrompt: (value: boolean) => void;
}
@@ -54,6 +61,7 @@ export const useSpaceWorkspaceSessionControls = ({
canStart,
currentSession,
goalInput,
linkedFocusPlanItemId,
selectedSceneId,
selectedTimerLabel,
selectedPresetId,
@@ -65,9 +73,10 @@ export const useSpaceWorkspaceSessionControls = ({
pauseSession,
resumeSession,
restartCurrentPhase,
completeSession,
advanceGoal,
abandonSession,
setGoalInput,
setLinkedFocusPlanItemId,
setSelectedGoalId,
setShowResumePrompt,
}: UseSpaceWorkspaceSessionControlsParams) => {
@@ -110,6 +119,7 @@ export const useSpaceWorkspaceSessionControls = ({
goal: trimmedGoal,
timerPresetId,
soundPresetId: selectedPresetId,
focusPlanItemId: linkedFocusPlanItemId ?? undefined,
entryPoint: pendingSessionEntryPoint,
});
@@ -129,6 +139,7 @@ export const useSpaceWorkspaceSessionControls = ({
selectedPresetId,
selectedSceneId,
selectedTimerLabel,
linkedFocusPlanItemId,
setPreviewPlaybackState,
startSession,
]);
@@ -222,33 +233,61 @@ export const useSpaceWorkspaceSessionControls = ({
const handleGoalAdvance = useCallback(async (nextGoal: string) => {
const trimmedNextGoal = nextGoal.trim();
const trimmedCurrentGoal = goalInput.trim();
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel);
if (!trimmedNextGoal) {
return;
if (!trimmedNextGoal || !trimmedCurrentGoal || !timerPresetId || !currentSession) {
return false;
}
if (currentSession) {
const completedSession = await completeSession({
completionType: 'goal-complete',
completedGoal: goalInput.trim(),
});
await unlockPlayback(resolveSoundPlaybackUrl(selectedPresetId));
if (!completedSession) {
pushStatusLine({
message: copy.space.workspace.goalCompleteSyncFailed,
});
return;
}
const nextState = await advanceGoal({
completedGoal: trimmedCurrentGoal,
nextGoal: trimmedNextGoal,
sceneId: selectedSceneId,
timerPresetId,
soundPresetId: selectedPresetId,
focusPlanItemId: linkedFocusPlanItemId ?? undefined,
});
if (!nextState) {
pushStatusLine({
message: copy.space.workspace.goalCompleteSyncFailed,
});
return false;
}
setGoalInput(trimmedNextGoal);
setLinkedFocusPlanItemId(nextState.nextSession.focusPlanItemId ?? null);
setSelectedGoalId(null);
setShowResumePrompt(false);
setPendingSessionEntryPoint('goal-complete');
setPreviewPlaybackState('paused');
setPreviewPlaybackState('running');
setWorkspaceMode('focus');
pushStatusLine({
message: copy.space.workspace.nextGoalReady,
message: copy.space.workspace.nextGoalStarted,
});
}, [completeSession, currentSession, goalInput, pushStatusLine, setGoalInput, setPendingSessionEntryPoint, setPreviewPlaybackState, setSelectedGoalId]);
return true;
}, [
advanceGoal,
currentSession,
goalInput,
linkedFocusPlanItemId,
pushStatusLine,
resolveSoundPlaybackUrl,
selectedPresetId,
selectedSceneId,
selectedTimerLabel,
setGoalInput,
setLinkedFocusPlanItemId,
setPendingSessionEntryPoint,
setPreviewPlaybackState,
setSelectedGoalId,
setShowResumePrompt,
setWorkspaceMode,
unlockPlayback,
]);
useEffect(() => {
const previousBodyOverflow = document.body.style.overflow;

View File

@@ -1,45 +1,48 @@
'use client';
"use client";
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { getSceneById, SCENE_THEMES } from '@/entities/scene';
import {
getSceneStageBackgroundStyle,
getSceneStagePhotoUrl,
preloadAssetImage,
useMediaCatalog,
} from '@/entities/media';
} from "@/entities/media";
import { getSceneById, SCENE_THEMES } from "@/entities/scene";
import { GOAL_CHIPS, SOUND_PRESETS, useThoughtInbox } from "@/entities/session";
import { useFocusSessionEngine } from "@/features/focus-session";
import {
GOAL_CHIPS,
SOUND_PRESETS,
useThoughtInbox,
} from '@/entities/session';
import { useFocusSessionEngine } from '@/features/focus-session';
import { useSoundPlayback, useSoundPresetSelection } from '@/features/sound-preset';
import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock';
import type { SessionEntryPoint, WorkspaceMode } from '../model/types';
import { useSpaceWorkspaceSelection } from '../model/useSpaceWorkspaceSelection';
import { useSpaceWorkspaceSessionControls } from '../model/useSpaceWorkspaceSessionControls';
useSoundPlayback,
useSoundPresetSelection,
} from "@/features/sound-preset";
import { useHudStatusLine } from "@/shared/lib/useHudStatusLine";
import { SpaceFocusHudWidget } from "@/widgets/space-focus-hud";
import { SpaceSetupDrawerWidget } from "@/widgets/space-setup-drawer";
import { SpaceToolsDockWidget } from "@/widgets/space-tools-dock";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import type { SessionEntryPoint, WorkspaceMode } from "../model/types";
import { useSpaceWorkspaceSelection } from "../model/useSpaceWorkspaceSelection";
import { useSpaceWorkspaceSessionControls } from "../model/useSpaceWorkspaceSessionControls";
import {
resolveFocusTimeDisplayFromTimerLabel,
resolveInitialSceneId,
resolveInitialSoundPreset,
resolveInitialTimerLabel,
resolveTimerLabelFromPresetId,
TIMER_SELECTION_PRESETS,
resolveFocusTimeDisplayFromTimerLabel,
} from '../model/workspaceSelection';
import { FocusTopToast } from './FocusTopToast';
} from "../model/workspaceSelection";
import { FocusTopToast } from "./FocusTopToast";
export const SpaceWorkspaceWidget = () => {
const searchParams = useSearchParams();
const sceneQuery = searchParams.get('scene') ?? searchParams.get('room');
const goalQuery = searchParams.get('goal')?.trim() ?? '';
const soundQuery = searchParams.get('sound');
const timerQuery = searchParams.get('timer');
const hasQueryOverrides = Boolean(sceneQuery || goalQuery || soundQuery || timerQuery);
const router = useRouter();
const sceneQuery = searchParams.get("scene") ?? searchParams.get("room");
const goalQuery = searchParams.get("goal")?.trim() ?? "";
const focusPlanItemIdQuery = searchParams.get("planItemId");
const soundQuery = searchParams.get("sound");
const timerQuery = searchParams.get("timer");
const hasQueryOverrides = Boolean(
sceneQuery || goalQuery || focusPlanItemIdQuery || soundQuery || timerQuery,
);
const {
thoughts,
@@ -60,22 +63,39 @@ export const SpaceWorkspaceWidget = () => {
hasResolvedManifest,
} = useMediaCatalog();
const initialSceneId = useMemo(() => resolveInitialSceneId(sceneQuery, undefined), [sceneQuery]);
const initialScene = useMemo(() => getSceneById(initialSceneId) ?? SCENE_THEMES[0], [initialSceneId]);
const initialSoundPresetId = useMemo(() => resolveInitialSoundPreset(
soundQuery,
undefined,
initialScene.recommendedSoundPresetId,
), [initialScene.recommendedSoundPresetId, soundQuery]);
const initialTimerLabel = useMemo(() => resolveInitialTimerLabel(
timerQuery,
undefined,
initialScene.recommendedTimerPresetId,
), [initialScene.recommendedTimerPresetId, timerQuery]);
const initialSceneId = useMemo(
() => resolveInitialSceneId(sceneQuery, undefined),
[sceneQuery],
);
const initialScene = useMemo(
() => getSceneById(initialSceneId) ?? SCENE_THEMES[0],
[initialSceneId],
);
const initialSoundPresetId = useMemo(
() =>
resolveInitialSoundPreset(
soundQuery,
undefined,
initialScene.recommendedSoundPresetId,
),
[initialScene.recommendedSoundPresetId, soundQuery],
);
const initialTimerLabel = useMemo(
() =>
resolveInitialTimerLabel(
timerQuery,
undefined,
initialScene.recommendedTimerPresetId,
),
[initialScene.recommendedTimerPresetId, timerQuery],
);
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>('setup');
const [previewPlaybackState, setPreviewPlaybackState] = useState<'running' | 'paused'>('paused');
const [pendingSessionEntryPoint, setPendingSessionEntryPoint] = useState<SessionEntryPoint>('space-setup');
const [workspaceMode, setWorkspaceMode] = useState<WorkspaceMode>("setup");
const [previewPlaybackState, setPreviewPlaybackState] = useState<
"running" | "paused"
>("paused");
const [pendingSessionEntryPoint, setPendingSessionEntryPoint] =
useState<SessionEntryPoint>("space-setup");
const {
selectedPresetId,
@@ -85,9 +105,9 @@ export const SpaceWorkspaceWidget = () => {
isMuted,
setMuted,
} = useSoundPresetSelection(initialSoundPresetId);
const {
currentSession,
isBootstrapping,
isMutating: isSessionMutating,
timeDisplay,
playbackState,
@@ -97,15 +117,16 @@ export const SpaceWorkspaceWidget = () => {
resumeSession,
restartCurrentPhase,
updateCurrentSelection,
completeSession,
advanceGoal,
abandonSession,
} = useFocusSessionEngine();
const isFocusMode = workspaceMode === 'focus';
const isFocusMode = workspaceMode === "focus";
const resolvedPlaybackState = currentSession?.state ?? previewPlaybackState;
const shouldPlaySound = isFocusMode && resolvedPlaybackState === 'running';
const shouldPlaySound = isFocusMode && resolvedPlaybackState === "running";
const { activeStatus, pushStatusLine, runActiveAction } = useHudStatusLine(isFocusMode);
const { activeStatus, pushStatusLine, runActiveAction } =
useHudStatusLine(isFocusMode);
const { error: soundPlaybackError, unlockPlayback } = useSoundPlayback({
selectedPresetId,
@@ -116,7 +137,7 @@ export const SpaceWorkspaceWidget = () => {
});
const resolveSoundPlaybackUrl = (presetId: string) => {
if (presetId === 'silent') {
if (presetId === "silent") {
return null;
}
const asset = soundAssetMap[presetId];
@@ -126,6 +147,7 @@ export const SpaceWorkspaceWidget = () => {
const selection = useSpaceWorkspaceSelection({
initialSceneId,
initialGoal: goalQuery,
initialFocusPlanItemId: focusPlanItemIdQuery,
initialTimerLabel,
sceneQuery,
goalQuery,
@@ -156,6 +178,7 @@ export const SpaceWorkspaceWidget = () => {
canStart: selection.canStart,
currentSession,
goalInput: selection.goalInput,
linkedFocusPlanItemId: selection.linkedFocusPlanItemId,
selectedSceneId: selection.selectedSceneId,
selectedTimerLabel: selection.selectedTimerLabel,
selectedPresetId,
@@ -167,27 +190,47 @@ export const SpaceWorkspaceWidget = () => {
pauseSession,
resumeSession,
restartCurrentPhase,
completeSession,
advanceGoal,
abandonSession,
setGoalInput: selection.setGoalInput,
setLinkedFocusPlanItemId: selection.setLinkedFocusPlanItemId,
setSelectedGoalId: selection.setSelectedGoalId,
setShowResumePrompt: selection.setShowResumePrompt,
});
useEffect(() => {
if (!isBootstrapping && !currentSession && !hasQueryOverrides) {
router.replace("/app");
}
}, [isBootstrapping, currentSession, hasQueryOverrides, router]);
useEffect(() => {
const preferMobile =
typeof window !== 'undefined' ? window.matchMedia('(max-width: 767px)').matches : false;
preloadAssetImage(getSceneStagePhotoUrl(selection.selectedScene, selection.selectedSceneAsset, { preferMobile }));
typeof window !== "undefined"
? window.matchMedia("(max-width: 767px)").matches
: false;
preloadAssetImage(
getSceneStagePhotoUrl(
selection.selectedScene,
selection.selectedSceneAsset,
{ preferMobile },
),
);
}, [selection.selectedScene, selection.selectedSceneAsset]);
const resolvedTimeDisplay = timeDisplay ?? resolveFocusTimeDisplayFromTimerLabel(selection.selectedTimerLabel);
const resolvedTimeDisplay =
timeDisplay ??
resolveFocusTimeDisplayFromTimerLabel(selection.selectedTimerLabel);
return (
<div className="relative h-dvh overflow-hidden text-white">
<div
aria-hidden
className="absolute -inset-8 bg-cover bg-center will-change-transform animate-[space-stage-pan_42s_ease-in-out_infinite_alternate] motion-reduce:animate-none"
style={getSceneStageBackgroundStyle(selection.selectedScene, selection.selectedSceneAsset)}
style={getSceneStageBackgroundStyle(
selection.selectedScene,
selection.selectedSceneAsset,
)}
/>
<div className="relative z-10 flex h-full flex-col">
@@ -208,39 +251,47 @@ export const SpaceWorkspaceWidget = () => {
timerPresets={TIMER_SELECTION_PRESETS}
canStart={selection.canStart}
onSceneSelect={selection.handleSelectScene}
onTimerSelect={(timerLabel) => selection.handleSelectTimer(timerLabel, true)}
onSoundSelect={(presetId) => selection.handleSelectSound(presetId, true)}
onTimerSelect={(timerLabel) =>
selection.handleSelectTimer(timerLabel, true)
}
onSoundSelect={(presetId) =>
selection.handleSelectSound(presetId, true)
}
onGoalChange={selection.handleGoalChange}
onGoalChipSelect={selection.handleGoalChipSelect}
onStart={controls.handleSetupFocusOpen}
resumeHint={
selection.showResumePrompt && selection.resumeGoal
? {
goal: selection.resumeGoal,
onResume: () => {
selection.setGoalInput(selection.resumeGoal);
selection.setSelectedGoalId(null);
selection.setShowResumePrompt(false);
controls.openFocusMode(selection.resumeGoal, 'resume-restore');
},
onStartFresh: () => {
selection.setGoalInput('');
selection.setSelectedGoalId(null);
selection.setShowResumePrompt(false);
},
}
goal: selection.resumeGoal,
onResume: () => {
selection.setGoalInput(selection.resumeGoal);
selection.setSelectedGoalId(null);
selection.setShowResumePrompt(false);
controls.openFocusMode(
selection.resumeGoal,
"resume-restore",
);
},
onStartFresh: () => {
selection.setGoalInput("");
selection.setSelectedGoalId(null);
selection.setShowResumePrompt(false);
},
}
: undefined
}
/>
<SpaceFocusHudWidget
goal={selection.goalInput.trim()}
microStep={currentSession?.microStep ?? null}
timerLabel={selection.selectedTimerLabel}
timeDisplay={resolvedTimeDisplay}
visible={isFocusMode}
hasActiveSession={Boolean(currentSession)}
playbackState={resolvedPlaybackState}
sessionPhase={phase ?? 'focus'}
sessionPhase={phase ?? "focus"}
isSessionActionPending={isSessionMutating}
canStartSession={controls.canStartSession}
canPauseSession={controls.canPauseSession}
@@ -260,7 +311,7 @@ export const SpaceWorkspaceWidget = () => {
<FocusTopToast
visible={isFocusMode && Boolean(activeStatus)}
message={activeStatus?.message ?? ''}
message={activeStatus?.message ?? ""}
actionLabel={activeStatus?.action?.label}
onAction={runActiveAction}
/>
@@ -276,15 +327,25 @@ export const SpaceWorkspaceWidget = () => {
thoughtCount={thoughtCount}
selectedPresetId={selectedPresetId}
onSceneSelect={selection.handleSelectScene}
onTimerSelect={(timerLabel) => selection.handleSelectTimer(timerLabel, true)}
onQuickSoundSelect={(presetId) => selection.handleSelectSound(presetId, true)}
onTimerSelect={(timerLabel) =>
selection.handleSelectTimer(timerLabel, true)
}
onQuickSoundSelect={(presetId) =>
selection.handleSelectSound(presetId, true)
}
sceneRecommendedSoundLabel={selection.selectedScene.recommendedSound}
sceneRecommendedTimerLabel={resolveTimerLabelFromPresetId(selection.selectedScene.recommendedTimerPresetId) ?? selection.selectedTimerLabel}
sceneRecommendedTimerLabel={
resolveTimerLabelFromPresetId(
selection.selectedScene.recommendedTimerPresetId,
) ?? selection.selectedTimerLabel
}
soundVolume={masterVolume}
onSetSoundVolume={setMasterVolume}
isSoundMuted={isMuted}
onSetSoundMuted={setMuted}
onCaptureThought={(note) => addThought(note, selection.selectedScene.name)}
onCaptureThought={(note) =>
addThought(note, selection.selectedScene.name)
}
onDeleteThought={removeThought}
onSetThoughtCompleted={setThoughtCompleted}
onRestoreThought={restoreThought}