feat(app-hub): 씬 중심 허브 화면으로 전면 리빌드

맥락:
- /app 첫 화면의 정보량이 많아 LifeAt/Portal 같은 몰입형 경험과 거리가 있었습니다.
- Start, 공간 선택, 메모 진입을 점진 노출 구조로 재정렬할 필요가 있었습니다.

변경사항:
- AppHub를 데스크톱 2열(컨트롤/씬) 구조와 모바일 스택 구조로 재편했습니다.
- Start CTA를 컴팩트 위계로 정리하고 커스텀 액션을 2순위 텍스트형으로 유지했습니다.
- Room 영역을 선택 Hero 1개 + 추천 썸네일 스트립 + 더보기 시트 구조로 전면 변경했습니다.
- 최근 생각은 단일 진입점 + 인박스 시트로 통합하고 localStorage 기반 thought inbox를 추가했습니다.
- /space 종료 동선에 세션 요약 시트(최근 메모 3개)를 연결해 허브 복귀 흐름을 정리했습니다.
- AppHub 기본 비주얼 모드를 cinematic으로 고정하고 배경 오버레이를 가독성 중심으로 재조정했습니다.

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

세션-상태: 씬 중심 허브 리빌드와 메모/시트 기반 점진 노출 구조 반영 완료
세션-다음: 실제 사용자 테스트 후 카드/텍스트 밀도 미세 조정
세션-리스크: 실디바이스 대비(특히 저사양 모바일)에서 배경/블러 렌더링 비용 확인 필요
This commit is contained in:
2026-03-01 20:06:57 +09:00
parent 85488f542e
commit 47e80e59d2
20 changed files with 1034 additions and 131 deletions

View File

@@ -8,16 +8,24 @@ import {
SOUND_PRESETS,
TIMER_PRESETS,
TODAY_ONE_LINER,
useThoughtInbox,
type GoalChip,
} from '@/entities/session';
import { MOCK_VIEWER } from '@/entities/user';
import { type CustomEntrySelection } from '@/features/custom-entry-modal';
import { useRoomSelection } from '@/features/room-select';
import {
DEFAULT_APP_HUB_VISUAL_MODE,
type AppHubVisualMode,
} from '@/shared/config/appHubVisualMode';
import { cn } from '@/shared/lib/cn';
import { useToast } from '@/shared/ui';
import { AppTopBar } from '@/widgets/app-top-bar/ui/AppTopBar';
import { CustomEntryWidget } from '@/widgets/custom-entry-widget/ui/CustomEntryWidget';
import { RoomsGalleryWidget } from '@/widgets/rooms-gallery-widget/ui/RoomsGalleryWidget';
import { StartRitualWidget } from '@/widgets/start-ritual-widget/ui/StartRitualWidget';
import { ThoughtInboxSheet } from '@/widgets/thought-inbox-sheet';
import { ThoughtSummaryEntryWidget } from '@/widgets/thought-summary-entry';
const buildSpaceQuery = (
roomId: string,
@@ -43,6 +51,7 @@ const buildSpaceQuery = (
export const AppHubWidget = () => {
const router = useRouter();
const { pushToast } = useToast();
const { thoughts, thoughtCount, clearThoughts } = useThoughtInbox();
const { selectedRoom, selectedRoomId, selectRoom } = useRoomSelection(
ROOM_THEMES[0].id,
);
@@ -50,6 +59,10 @@ export const AppHubWidget = () => {
const [goalInput, setGoalInput] = useState('');
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
const [isCustomEntryOpen, setCustomEntryOpen] = useState(false);
const [isThoughtInboxOpen, setThoughtInboxOpen] = useState(false);
const visualMode: AppHubVisualMode = DEFAULT_APP_HUB_VISUAL_MODE;
const cinematic = visualMode === 'cinematic';
const enterSpace = (soundPresetId: string, timerLabel: string) => {
const query = buildSpaceQuery(
@@ -95,50 +108,85 @@ export const AppHubWidget = () => {
<div className="relative min-h-screen overflow-hidden text-white">
<div
aria-hidden
className="absolute inset-0 scale-[1.04] blur-[18px]"
className={cn(
'absolute inset-0',
cinematic && 'scale-[1.01]',
)}
style={{
...getRoomCardBackgroundStyle(selectedRoom),
filter: 'brightness(1.05) saturate(0.9)',
filter: cinematic
? 'brightness(0.86) saturate(1.06) contrast(1.03)'
: 'brightness(0.94) saturate(0.96)',
}}
/>
<div
aria-hidden
className="absolute inset-0 bg-[radial-gradient(circle_at_14%_0%,rgba(255,255,255,0.54),transparent_48%),radial-gradient(circle_at_86%_18%,rgba(255,255,255,0.36),transparent_46%),linear-gradient(165deg,rgba(248,250,252,0.26)_0%,rgba(226,232,240,0.22)_52%,rgba(203,213,225,0.3)_100%)]"
className={cn(
'absolute inset-0',
cinematic
? 'bg-[linear-gradient(180deg,rgba(2,6,23,0.22)_0%,rgba(2,6,23,0.34)_50%,rgba(2,6,23,0.52)_100%)]'
: 'bg-[linear-gradient(180deg,rgba(248,250,252,0.44)_0%,rgba(241,245,249,0.22)_42%,rgba(15,23,42,0.3)_100%)]',
)}
/>
<div
aria-hidden
className="absolute inset-0 opacity-[0.14]"
className={cn(
'absolute inset-0',
cinematic ? 'opacity-[0.12]' : 'opacity-[0.06]',
)}
style={{
backgroundImage:
"url('/textures/grain.png'), repeating-linear-gradient(0deg, rgba(255,255,255,0.022) 0 1px, transparent 1px 2px)",
"url('/textures/grain.png'), repeating-linear-gradient(0deg, rgba(255,255,255,0.016) 0 1px, transparent 1px 2px)",
mixBlendMode: 'soft-light',
}}
/>
<div
aria-hidden
className="absolute inset-0 bg-[radial-gradient(circle_at_50%_74%,transparent_12%,rgba(2,6,23,0.32)_100%)]"
/>
<div className="relative z-10 flex min-h-screen flex-col">
<AppTopBar
user={MOCK_VIEWER}
oneLiner={TODAY_ONE_LINER}
onLogout={handleLogout}
visualMode={visualMode}
thoughtCount={thoughtCount}
onOpenThoughtInbox={() => setThoughtInboxOpen(true)}
onOpenBilling={() => router.push('/settings')}
/>
<main className="mx-auto w-full max-w-7xl flex-1 px-4 pb-8 pt-6 sm:px-6 lg:px-8">
<div className="grid gap-6 xl:grid-cols-[minmax(320px,420px),1fr]">
<StartRitualWidget
goalInput={goalInput}
selectedGoalId={selectedGoalId}
goalChips={GOAL_CHIPS}
onGoalInputChange={handleGoalInputChange}
onGoalChipSelect={handleGoalChipSelect}
onQuickEnter={handleQuickEnter}
onOpenCustomEntry={() => setCustomEntryOpen(true)}
/>
<main className="mx-auto w-full max-w-6xl flex-1 px-4 pb-8 pt-5 sm:px-6 sm:pt-6 lg:px-8 lg:pb-10">
<div className="grid gap-4 lg:grid-cols-[minmax(0,360px)_minmax(0,1fr)] lg:gap-5">
<section className="order-1">
<StartRitualWidget
visualMode={visualMode}
goalInput={goalInput}
selectedGoalId={selectedGoalId}
goalChips={GOAL_CHIPS}
onGoalInputChange={handleGoalInputChange}
onGoalChipSelect={handleGoalChipSelect}
onQuickEnter={handleQuickEnter}
onOpenCustomEntry={() => setCustomEntryOpen(true)}
/>
</section>
<RoomsGalleryWidget
rooms={ROOM_THEMES}
selectedRoomId={selectedRoomId}
onRoomSelect={selectRoom}
/>
<section className="order-2 lg:row-span-2">
<RoomsGalleryWidget
visualMode={visualMode}
rooms={ROOM_THEMES}
selectedRoomId={selectedRoomId}
onRoomSelect={selectRoom}
/>
</section>
<section className="order-3">
<ThoughtSummaryEntryWidget
visualMode={visualMode}
thoughtCount={thoughtCount}
onOpen={() => setThoughtInboxOpen(true)}
/>
</section>
</div>
</main>
</div>
@@ -150,6 +198,16 @@ export const AppHubWidget = () => {
onClose={() => setCustomEntryOpen(false)}
onEnter={handleCustomEnter}
/>
<ThoughtInboxSheet
isOpen={isThoughtInboxOpen}
thoughts={thoughts}
onClose={() => setThoughtInboxOpen(false)}
onClear={() => {
clearThoughts();
pushToast({ title: '메모 인박스를 비웠어요' });
}}
/>
</div>
);
};