feat(app-hub): 허브 도구 레일과 입장 동선을 정리

맥락:\n- 상단 헤더 요소가 많아 허브의 핵심 흐름(공간 선택 → 목표 입력 → 입장)이 분산되었습니다.\n- 메모/통계/설정 진입을 상단이 아닌 보조 동선으로 옮겨 감성 톤을 유지할 필요가 있었습니다.\n\n변경사항:\n- 우측 아이콘 레일과 우측 드로어를 추가해 Inbox/Stats/Settings를 동일 패턴으로 제공했습니다.\n- TopBar에서 메모 버튼을 제거하고, 멤버십/PRO 동선은 ProfileMenu 드롭다운으로 정리했습니다.\n- Selected Space 박스를 슬림화하고 설명을 1줄로 제한해 시선 분산을 줄였습니다.\n- 추천 공간 카드에 텍스트 가독성 오버레이와 선택 표시(은은한 테두리/체크)를 적용했습니다.\n- Quick Entry에서 커스텀 CTA 무게를 낮추고 전체 톤을 가볍게 조정했습니다.\n- docs/work.template.md를 추가하고 docs/work.md의 현재 작업 지시를 갱신했습니다.\n\n검증:\n- npm run build\n- npx tsc --noEmit\n\n세션-상태: /app 허브가 상단 과밀 없이 레일+드로어 보조 동선으로 정리되었습니다.\n세션-다음: 드로어 패널의 실제 데이터 연결 시 도메인 상태와 연동을 진행합니다.\n세션-리스크: 드로어 포커스 트랩/키보드 동선은 추가 접근성 점검이 필요합니다.
This commit is contained in:
2026-03-02 12:17:35 +09:00
parent 6dfa4677d9
commit a2bebb3485
16 changed files with 610 additions and 164 deletions

View File

@@ -1,48 +1,2 @@
# Work Order 프로필 왼쪽의 등급 칩의 글자가 너무 작다.
실제로 돈을 낸 사람들이 대우를 받을 수 있다는 느낌이 들도록 좀더 세련된 느낌을 줘야한다.
이 파일은 사용자가 이번 세션에서 처리할 작업을 적는 실행 입력서다.
## 작성 규칙
- 작업은 가능한 한 "주제별"로 분리해서 작성한다.
- 한 주제는 가능하면 한 커밋으로 끝낼 수 있게 범위를 좁힌다.
- "금지사항/제외 범위"를 명시해서 불필요한 변경을 막는다.
## 작업 템플릿
아래 블록을 복사해서 사용:
```md
## 작업 1
- 제목:
- 목적:
- 변경 범위:
-
- 제외 범위:
-
- 완료 조건:
-
- 검증:
- npx tsc --noEmit
- 커밋 힌트:
- type(scope): 한국어 요약
## 작업 2 (선택)
- 제목:
- 목적:
- 변경 범위:
-
- 제외 범위:
-
- 완료 조건:
-
- 검증:
-
- 커밋 힌트:
-
```
## 우선순위
- 위에서 아래 순서대로 높은 우선순위로 간주한다.
- `작업 1`을 먼저 처리하고, 완료 시 다음 작업으로 넘어간다.

48
docs/work.template.md Normal file
View File

@@ -0,0 +1,48 @@
# Work Order
이 파일은 사용자가 이번 세션에서 처리할 작업을 적는 실행 입력서다.
## 작성 규칙
- 작업은 가능한 한 "주제별"로 분리해서 작성한다.
- 한 주제는 가능하면 한 커밋으로 끝낼 수 있게 범위를 좁힌다.
- "금지사항/제외 범위"를 명시해서 불필요한 변경을 막는다.
## 작업 템플릿
아래 블록을 복사해서 사용:
```md
## 작업 1
- 제목:
- 목적:
- 변경 범위:
-
- 제외 범위:
-
- 완료 조건:
-
- 검증:
- npx tsc --noEmit
- 커밋 힌트:
- type(scope): 한국어 요약
## 작업 2 (선택)
- 제목:
- 목적:
- 변경 범위:
-
- 제외 범위:
-
- 완료 조건:
-
- 검증:
-
- 커밋 힌트:
-
```
## 우선순위
- 위에서 아래 순서대로 높은 우선순위로 간주한다.
- `작업 1`을 먼저 처리하고, 완료 시 다음 작업으로 넘어간다.

View File

@@ -1,12 +1,20 @@
import type { ViewerProfile } from '@/entities/user'; import type { ViewerProfile } from '@/entities/user';
import { Dropdown, DropdownItem } from '@/shared/ui'; import { Dropdown, DropdownItem } from '@/shared/ui';
import { cn } from '@/shared/lib/cn';
interface ProfileMenuProps { interface ProfileMenuProps {
user: ViewerProfile; user: ViewerProfile;
onLogout: () => void; onLogout: () => void;
onOpenBilling?: () => void;
} }
export const ProfileMenu = ({ user, onLogout }: ProfileMenuProps) => { const MEMBERSHIP_LABEL_MAP: Record<ViewerProfile['membershipTier'], string> = {
pro: 'PRO MEMBER',
normal: 'NORMAL',
team: 'TEAM',
};
export const ProfileMenu = ({ user, onLogout, onOpenBilling }: ProfileMenuProps) => {
return ( return (
<Dropdown <Dropdown
align="right" align="right"
@@ -21,6 +29,20 @@ export const ProfileMenu = ({ user, onLogout }: ProfileMenuProps) => {
</span> </span>
} }
> >
<div className="rounded-lg px-3 py-2">
<p className="text-[11px] uppercase tracking-[0.14em] text-white/48">Membership</p>
<p
className={cn(
'mt-1 text-xs font-medium',
user.membershipTier === 'pro' ? 'text-amber-200/88' : 'text-white/80',
)}
>
{MEMBERSHIP_LABEL_MAP[user.membershipTier]}
</p>
</div>
{onOpenBilling ? (
<DropdownItem onClick={onOpenBilling}>PRO </DropdownItem>
) : null}
<DropdownItem href="/stats">Stats</DropdownItem> <DropdownItem href="/stats">Stats</DropdownItem>
<DropdownItem href="/settings">Settings</DropdownItem> <DropdownItem href="/settings">Settings</DropdownItem>
<DropdownItem danger onClick={onLogout}> <DropdownItem danger onClick={onLogout}>

View File

@@ -31,8 +31,14 @@ export const RoomPreviewCard = ({
compact compact
? 'h-[98px] min-w-[152px] snap-start p-2 sm:min-w-0 sm:aspect-[5/4]' ? 'h-[98px] min-w-[152px] snap-start p-2 sm:min-w-0 sm:aspect-[5/4]'
: 'h-[292px] p-3 sm:h-[334px] sm:p-4 lg:h-[356px]', : 'h-[292px] p-3 sm:h-[334px] sm:p-4 lg:h-[356px]',
compact &&
'after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-[62%] after:bg-[linear-gradient(to_top,rgba(2,6,23,0.88),rgba(2,6,23,0.34),rgba(2,6,23,0))]',
selected selected
? compact
? cinematic ? cinematic
? 'border-sky-200/54 shadow-[0_0_0_1px_rgba(186,230,253,0.36),0_0_20px_rgba(125,211,252,0.22)]'
: 'border-brand-dark/32 shadow-[0_0_0_1px_rgba(48,77,109,0.22),0_0_16px_rgba(99,173,242,0.18)]'
: cinematic
? 'border-sky-200/44 shadow-[0_0_0_1px_rgba(186,230,253,0.34),0_20px_50px_rgba(2,6,23,0.32)]' ? 'border-sky-200/44 shadow-[0_0_0_1px_rgba(186,230,253,0.34),0_20px_50px_rgba(2,6,23,0.32)]'
: 'border-brand-dark/28 shadow-[0_0_0_1px_rgba(48,77,109,0.18),0_16px_40px_rgba(15,23,42,0.16)]' : 'border-brand-dark/28 shadow-[0_0_0_1px_rgba(48,77,109,0.18),0_16px_40px_rgba(15,23,42,0.16)]'
: cinematic : cinematic
@@ -73,6 +79,18 @@ export const RoomPreviewCard = ({
)} )}
> >
{selected ? ( {selected ? (
compact ? (
<span
className={cn(
'absolute right-2.5 top-2.5 inline-flex h-5 w-5 items-center justify-center rounded-full border text-[11px] font-semibold',
cinematic
? 'border-sky-200/64 bg-sky-200/26 text-sky-50'
: 'border-brand-primary/52 bg-brand-primary/18 text-brand-primary',
)}
>
</span>
) : (
<span <span
className={cn( className={cn(
'absolute left-3 top-3 inline-flex items-center rounded-full border px-2 py-1 text-[10px] font-medium', 'absolute left-3 top-3 inline-flex items-center rounded-full border px-2 py-1 text-[10px] font-medium',
@@ -83,10 +101,11 @@ export const RoomPreviewCard = ({
> >
</span> </span>
)
) : null} ) : null}
{compact ? ( {compact ? (
<div className="w-full rounded-lg bg-slate-950/44 px-2 py-1.5"> <div className="w-full rounded-lg bg-[linear-gradient(180deg,rgba(2,6,23,0.28)_0%,rgba(2,6,23,0.58)_100%)] px-2 py-1.5">
<p className="truncate text-sm font-semibold text-white">{room.name}</p> <p className="truncate text-sm font-semibold text-white">{room.name}</p>
<p className="mt-0.5 truncate text-[11px] text-white/70">{room.vibeLabel}</p> <p className="mt-0.5 truncate text-[11px] text-white/70">{room.vibeLabel}</p>
</div> </div>

View File

@@ -20,12 +20,14 @@ import {
} from '@/shared/config/appHubVisualMode'; } from '@/shared/config/appHubVisualMode';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
import { useToast } from '@/shared/ui'; import { useToast } from '@/shared/ui';
import {
AppUtilityRailWidget,
type AppUtilityPanelId,
} from '@/widgets/app-utility-rail';
import { AppTopBar } from '@/widgets/app-top-bar/ui/AppTopBar'; import { AppTopBar } from '@/widgets/app-top-bar/ui/AppTopBar';
import { CustomEntryWidget } from '@/widgets/custom-entry-widget/ui/CustomEntryWidget'; import { CustomEntryWidget } from '@/widgets/custom-entry-widget/ui/CustomEntryWidget';
import { RoomsGalleryWidget } from '@/widgets/rooms-gallery-widget/ui/RoomsGalleryWidget'; import { RoomsGalleryWidget } from '@/widgets/rooms-gallery-widget/ui/RoomsGalleryWidget';
import { StartRitualWidget } from '@/widgets/start-ritual-widget/ui/StartRitualWidget'; 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 = ( const buildSpaceQuery = (
roomId: string, roomId: string,
@@ -59,7 +61,9 @@ export const AppHubWidget = () => {
const [goalInput, setGoalInput] = useState(''); const [goalInput, setGoalInput] = useState('');
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null); const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
const [isCustomEntryOpen, setCustomEntryOpen] = useState(false); const [isCustomEntryOpen, setCustomEntryOpen] = useState(false);
const [isThoughtInboxOpen, setThoughtInboxOpen] = useState(false); const [activeUtilityPanel, setActiveUtilityPanel] = useState<AppUtilityPanelId | null>(
null,
);
const visualMode: AppHubVisualMode = DEFAULT_APP_HUB_VISUAL_MODE; const visualMode: AppHubVisualMode = DEFAULT_APP_HUB_VISUAL_MODE;
const cinematic = visualMode === 'cinematic'; const cinematic = visualMode === 'cinematic';
@@ -147,8 +151,6 @@ export const AppHubWidget = () => {
oneLiner={TODAY_ONE_LINER} oneLiner={TODAY_ONE_LINER}
onLogout={handleLogout} onLogout={handleLogout}
visualMode={visualMode} visualMode={visualMode}
thoughtCount={thoughtCount}
onOpenThoughtInbox={() => setThoughtInboxOpen(true)}
onOpenBilling={() => router.push('/settings')} onOpenBilling={() => router.push('/settings')}
/> />
@@ -172,13 +174,6 @@ export const AppHubWidget = () => {
/> />
)} )}
/> />
<div className="mt-3 sm:mt-4">
<ThoughtSummaryEntryWidget
visualMode={visualMode}
thoughtCount={thoughtCount}
onOpen={() => setThoughtInboxOpen(true)}
/>
</div>
</main> </main>
</div> </div>
@@ -190,11 +185,14 @@ export const AppHubWidget = () => {
onEnter={handleCustomEnter} onEnter={handleCustomEnter}
/> />
<ThoughtInboxSheet <AppUtilityRailWidget
isOpen={isThoughtInboxOpen} visualMode={visualMode}
activePanel={activeUtilityPanel}
thoughts={thoughts} thoughts={thoughts}
onClose={() => setThoughtInboxOpen(false)} thoughtCount={thoughtCount}
onClear={() => { onOpenPanel={setActiveUtilityPanel}
onClosePanel={() => setActiveUtilityPanel(null)}
onClearInbox={() => {
clearThoughts(); clearThoughts();
pushToast({ title: '메모 인박스를 비웠어요' }); pushToast({ title: '메모 인박스를 비웠어요' });
}} }}

View File

@@ -1,4 +1,4 @@
import { MembershipTierBadge, type ViewerProfile } from '@/entities/user'; import type { ViewerProfile } from '@/entities/user';
import type { AppHubVisualMode } from '@/shared/config/appHubVisualMode'; import type { AppHubVisualMode } from '@/shared/config/appHubVisualMode';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
import { ProfileMenu } from '@/features/profile-menu'; import { ProfileMenu } from '@/features/profile-menu';
@@ -8,8 +8,6 @@ interface AppTopBarProps {
oneLiner: string; oneLiner: string;
onLogout: () => void; onLogout: () => void;
visualMode?: AppHubVisualMode; visualMode?: AppHubVisualMode;
thoughtCount?: number;
onOpenThoughtInbox?: () => void;
onOpenBilling?: () => void; onOpenBilling?: () => void;
} }
@@ -18,12 +16,9 @@ export const AppTopBar = ({
oneLiner, oneLiner,
onLogout, onLogout,
visualMode = 'light', visualMode = 'light',
thoughtCount = 0,
onOpenThoughtInbox,
onOpenBilling, onOpenBilling,
}: AppTopBarProps) => { }: AppTopBarProps) => {
const cinematic = visualMode === 'cinematic'; const cinematic = visualMode === 'cinematic';
const thoughtCountLabel = thoughtCount > 99 ? '99+' : `${thoughtCount}`;
return ( return (
<header className="relative z-20 px-4 pt-4 sm:px-6 lg:px-8"> <header className="relative z-20 px-4 pt-4 sm:px-6 lg:px-8">
@@ -38,66 +33,16 @@ export const AppTopBar = ({
<div className="min-w-0"> <div className="min-w-0">
<p <p
className={cn( className={cn(
'text-sm font-semibold tracking-tight', 'text-base font-semibold tracking-tight',
cinematic ? 'text-white/94' : 'text-brand-dark', cinematic ? 'text-white/94' : 'text-brand-dark',
)} )}
> >
VibeRoom <span title={oneLiner}>VibeRoom</span>
</p>
<p
className={cn(
'mt-0.5 hidden truncate text-xs sm:block',
cinematic ? 'text-white/62' : 'text-brand-dark/56',
)}
>
{oneLiner}
</p> </p>
</div> </div>
<div className="flex items-center gap-2.5 sm:gap-3"> <div className="flex items-center gap-2.5 sm:gap-3">
{onOpenBilling ? ( <ProfileMenu user={user} onLogout={onLogout} onOpenBilling={onOpenBilling} />
<button
type="button"
onClick={onOpenBilling}
className={cn(
'hidden text-xs font-medium transition-colors md:inline-flex',
cinematic
? 'text-white/62 hover:text-white/90'
: 'text-brand-dark/56 hover:text-brand-dark/84',
)}
>
PRO
</button>
) : null}
{onOpenThoughtInbox ? (
<button
type="button"
onClick={onOpenThoughtInbox}
className={cn(
'inline-flex h-8 items-center gap-1.5 rounded-full border px-2.5 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/75 sm:h-9 sm:px-3 sm:text-xs',
cinematic
? 'border-white/22 bg-white/10 text-white/86 hover:bg-white/16'
: 'border-brand-dark/14 bg-white/72 text-brand-dark/80 hover:bg-white/90',
)}
>
<span aria-hidden>📝</span>
<span className="hidden sm:inline"></span>
{thoughtCount > 0 ? (
<span
className={cn(
'inline-flex min-w-[1.2rem] items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
cinematic
? 'bg-sky-200/20 text-sky-100'
: 'bg-brand-primary/14 text-brand-primary/86',
)}
>
{thoughtCountLabel}
</span>
) : null}
</button>
) : null}
<MembershipTierBadge tier={user.membershipTier} />
<ProfileMenu user={user} onLogout={onLogout} />
</div> </div>
</div> </div>
</header> </header>

View File

@@ -0,0 +1,3 @@
export * from './model/types';
export * from './ui/AppUtilityRailWidget';

View File

@@ -0,0 +1,2 @@
export type AppUtilityPanelId = 'inbox' | 'stats' | 'settings';

View File

@@ -0,0 +1,91 @@
'use client';
import { useEffect, type ReactNode } from 'react';
import { cn } from '@/shared/lib/cn';
interface AppUtilityDrawerProps {
open: boolean;
title: string;
visualMode: 'light' | 'cinematic';
onClose: () => void;
children: ReactNode;
}
export const AppUtilityDrawer = ({
open,
title,
visualMode,
onClose,
children,
}: AppUtilityDrawerProps) => {
const cinematic = visualMode === 'cinematic';
useEffect(() => {
if (!open) {
return;
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [open, onClose]);
if (!open) {
return null;
}
return (
<>
<button
type="button"
aria-label="도구 드로어 닫기"
onClick={onClose}
className="fixed inset-0 z-40 bg-slate-950/22 backdrop-blur-[1px]"
/>
<aside className="fixed inset-y-0 right-0 z-50 w-[min(360px,92vw)] animate-[sheet-in_220ms_ease-out] p-2 sm:p-3">
<div
className={cn(
'flex h-full flex-col overflow-hidden rounded-3xl border backdrop-blur-2xl',
cinematic
? 'border-white/16 bg-slate-950/64 text-white shadow-[0_24px_70px_rgba(2,6,23,0.55)]'
: 'border-brand-dark/14 bg-white/84 text-brand-dark shadow-[0_24px_70px_rgba(15,23,42,0.2)]',
)}
>
<header
className={cn(
'flex items-center justify-between border-b px-4 py-3 sm:px-5',
cinematic ? 'border-white/10' : 'border-brand-dark/12',
)}
>
<h2 className={cn('text-base font-semibold', cinematic ? 'text-white' : 'text-brand-dark')}>
{title}
</h2>
<button
type="button"
onClick={onClose}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-full border text-sm transition-colors',
cinematic
? 'border-white/18 bg-white/8 text-white/80 hover:bg-white/14 hover:text-white'
: 'border-brand-dark/14 bg-white/66 text-brand-dark/72 hover:bg-white hover:text-brand-dark',
)}
aria-label="닫기"
>
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5">{children}</div>
</div>
</aside>
</>
);
};

View File

@@ -0,0 +1,121 @@
'use client';
import type { RecentThought } from '@/entities/session';
import type { AppHubVisualMode } from '@/shared/config/appHubVisualMode';
import { cn } from '@/shared/lib/cn';
import { AppUtilityDrawer } from './AppUtilityDrawer';
import { type AppUtilityPanelId } from '../model/types';
import { InboxDrawerPanel } from './panels/InboxDrawerPanel';
import { SettingsDrawerPanel } from './panels/SettingsDrawerPanel';
import { StatsDrawerPanel } from './panels/StatsDrawerPanel';
interface AppUtilityRailWidgetProps {
visualMode: AppHubVisualMode;
activePanel: AppUtilityPanelId | null;
thoughts: RecentThought[];
thoughtCount: number;
onOpenPanel: (panel: AppUtilityPanelId) => void;
onClosePanel: () => void;
onClearInbox: () => void;
}
const RAIL_ITEMS: Array<{
id: AppUtilityPanelId;
icon: string;
label: string;
}> = [
{ id: 'inbox', icon: '📨', label: 'Inbox' },
{ id: 'stats', icon: '📊', label: 'Stats' },
{ id: 'settings', icon: '⚙', label: 'Settings' },
];
const PANEL_TITLE_MAP: Record<AppUtilityPanelId, string> = {
inbox: '임시 보관함',
stats: '집중 요약',
settings: '설정',
};
export const AppUtilityRailWidget = ({
visualMode,
activePanel,
thoughts,
thoughtCount,
onOpenPanel,
onClosePanel,
onClearInbox,
}: AppUtilityRailWidgetProps) => {
const cinematic = visualMode === 'cinematic';
return (
<>
<div className="fixed right-3 top-1/2 z-30 -translate-y-1/2">
<div
className={cn(
'flex w-12 flex-col items-center gap-2 rounded-2xl border py-2 backdrop-blur-xl',
cinematic
? 'border-white/18 bg-slate-950/34 shadow-[0_18px_32px_rgba(2,6,23,0.42)]'
: 'border-brand-dark/12 bg-white/54 shadow-[0_18px_32px_rgba(15,23,42,0.14)]',
)}
>
{RAIL_ITEMS.map((item) => {
const selected = activePanel === item.id;
return (
<button
key={item.id}
type="button"
title={item.label}
aria-label={item.label}
onClick={() => onOpenPanel(item.id)}
className={cn(
'relative inline-flex h-9 w-9 items-center justify-center rounded-xl border text-base transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/75',
selected
? cinematic
? 'border-sky-200/64 bg-sky-200/22'
: 'border-brand-primary/44 bg-brand-primary/14'
: cinematic
? 'border-white/18 bg-white/8 hover:bg-white/14'
: 'border-brand-dark/14 bg-white/72 hover:bg-white',
)}
>
<span aria-hidden>{item.icon}</span>
{item.id === 'inbox' && thoughtCount > 0 ? (
<span
className={cn(
'absolute -right-1 -top-1 inline-flex min-w-[1rem] items-center justify-center rounded-full px-1 py-0.5 text-[9px] font-semibold',
cinematic
? 'bg-sky-200/28 text-sky-50'
: 'bg-brand-primary/18 text-brand-primary',
)}
>
{thoughtCount > 99 ? '99+' : `${thoughtCount}`}
</span>
) : null}
</button>
);
})}
</div>
</div>
<AppUtilityDrawer
open={activePanel !== null}
title={activePanel ? PANEL_TITLE_MAP[activePanel] : ''}
visualMode={visualMode}
onClose={onClosePanel}
>
{activePanel === 'inbox' ? (
<InboxDrawerPanel
visualMode={visualMode}
thoughts={thoughts}
onClear={onClearInbox}
/>
) : null}
{activePanel === 'stats' ? <StatsDrawerPanel visualMode={visualMode} /> : null}
{activePanel === 'settings' ? (
<SettingsDrawerPanel visualMode={visualMode} />
) : null}
</AppUtilityDrawer>
</>
);
};

View File

@@ -0,0 +1,78 @@
import type { RecentThought } from '@/entities/session';
import { cn } from '@/shared/lib/cn';
interface InboxDrawerPanelProps {
visualMode: 'light' | 'cinematic';
thoughts: RecentThought[];
onClear: () => void;
}
export const InboxDrawerPanel = ({
visualMode,
thoughts,
onClear,
}: InboxDrawerPanelProps) => {
const cinematic = visualMode === 'cinematic';
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className={cn('text-xs', cinematic ? 'text-white/56' : 'text-brand-dark/54')}>
</p>
<button
type="button"
onClick={onClear}
className={cn(
'rounded-full border px-2.5 py-1 text-[11px] transition-colors',
cinematic
? 'border-white/20 bg-white/8 text-white/72 hover:bg-white/14 hover:text-white'
: 'border-brand-dark/14 bg-white/66 text-brand-dark/70 hover:bg-white hover:text-brand-dark',
)}
>
</button>
</div>
{thoughts.length === 0 ? (
<p
className={cn(
'rounded-2xl border px-3.5 py-3 text-sm leading-relaxed',
cinematic
? 'border-white/14 bg-white/6 text-white/74'
: 'border-brand-dark/12 bg-white/70 text-brand-dark/72',
)}
>
. .
</p>
) : (
<ul className="space-y-2.5">
{thoughts.slice(0, 10).map((thought) => (
<li
key={thought.id}
className={cn(
'rounded-2xl border px-3.5 py-3',
cinematic
? 'border-white/14 bg-white/7'
: 'border-brand-dark/12 bg-white/72',
)}
>
<p className={cn('text-sm leading-relaxed', cinematic ? 'text-white/88' : 'text-brand-dark/84')}>
{thought.text}
</p>
<div
className={cn(
'mt-2 flex items-center justify-end text-[11px]',
cinematic ? 'text-white/54' : 'text-brand-dark/52',
)}
>
{thought.capturedAt}
</div>
</li>
))}
</ul>
)}
</div>
);
};

View File

@@ -0,0 +1,98 @@
'use client';
import { useState } from 'react';
import { DEFAULT_PRESET_OPTIONS } from '@/shared/config/settingsOptions';
import { cn } from '@/shared/lib/cn';
interface SettingsDrawerPanelProps {
visualMode: 'light' | 'cinematic';
}
export const SettingsDrawerPanel = ({ visualMode }: SettingsDrawerPanelProps) => {
const cinematic = visualMode === 'cinematic';
const [reduceMotion, setReduceMotion] = useState(false);
const [defaultPresetId, setDefaultPresetId] = useState<
(typeof DEFAULT_PRESET_OPTIONS)[number]['id']
>(DEFAULT_PRESET_OPTIONS[0].id);
return (
<div className="space-y-4">
<section
className={cn(
'rounded-2xl border px-3.5 py-3',
cinematic
? 'border-white/14 bg-white/7'
: 'border-brand-dark/12 bg-white/72',
)}
>
<div className="flex items-center justify-between gap-3">
<div>
<p className={cn('text-sm font-medium', cinematic ? 'text-white' : 'text-brand-dark')}>
Reduce Motion
</p>
<p className={cn('mt-1 text-xs', cinematic ? 'text-white/58' : 'text-brand-dark/54')}>
.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={reduceMotion}
onClick={() => setReduceMotion((current) => !current)}
className={cn(
'inline-flex w-14 items-center rounded-full border px-1 py-1 transition-colors',
reduceMotion
? cinematic
? 'border-sky-200/42 bg-sky-200/24'
: 'border-brand-primary/45 bg-brand-soft/60'
: cinematic
? 'border-white/24 bg-white/9'
: 'border-brand-dark/20 bg-white/86',
)}
>
<span
className={cn(
'h-4.5 w-4.5 rounded-full bg-white transition-transform duration-200 motion-reduce:transition-none',
reduceMotion ? 'translate-x-7' : 'translate-x-0',
)}
/>
</button>
</div>
</section>
<section
className={cn(
'rounded-2xl border px-3.5 py-3',
cinematic
? 'border-white/14 bg-white/7'
: 'border-brand-dark/12 bg-white/72',
)}
>
<p className={cn('text-sm font-medium', cinematic ? 'text-white' : 'text-brand-dark')}>
</p>
<div className="mt-2 flex flex-wrap gap-2">
{DEFAULT_PRESET_OPTIONS.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => setDefaultPresetId(preset.id)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs transition-colors',
defaultPresetId === preset.id
? cinematic
? 'border-sky-200/44 bg-sky-200/20 text-sky-100'
: 'border-brand-primary/45 bg-brand-soft/58 text-brand-dark'
: cinematic
? 'border-white/18 bg-white/8 text-white/80 hover:bg-white/14'
: 'border-brand-dark/16 bg-white/74 text-brand-dark/78 hover:bg-white',
)}
>
{preset.label}
</button>
))}
</div>
</section>
</div>
);
};

View File

@@ -0,0 +1,64 @@
import { TODAY_STATS, WEEKLY_STATS } from '@/entities/session';
import { cn } from '@/shared/lib/cn';
interface StatsDrawerPanelProps {
visualMode: 'light' | 'cinematic';
}
export const StatsDrawerPanel = ({ visualMode }: StatsDrawerPanelProps) => {
const cinematic = visualMode === 'cinematic';
return (
<div className="space-y-4">
<p className={cn('text-xs', cinematic ? 'text-white/56' : 'text-brand-dark/54')}>
7 .
</p>
<section className="grid gap-2.5 sm:grid-cols-2">
{[TODAY_STATS[0], TODAY_STATS[1], WEEKLY_STATS[0], WEEKLY_STATS[2]].map((stat) => (
<article
key={stat.id}
className={cn(
'rounded-2xl border px-3.5 py-3',
cinematic
? 'border-white/14 bg-white/7'
: 'border-brand-dark/12 bg-white/72',
)}
>
<p className={cn('text-[11px]', cinematic ? 'text-white/58' : 'text-brand-dark/54')}>
{stat.label}
</p>
<p className={cn('mt-1 text-lg font-semibold', cinematic ? 'text-white' : 'text-brand-dark')}>
{stat.value}
</p>
<p className={cn('mt-0.5 text-xs', cinematic ? 'text-sky-100/78' : 'text-brand-primary/86')}>
{stat.delta}
</p>
</article>
))}
</section>
<section
className={cn(
'rounded-2xl border p-3.5',
cinematic
? 'border-white/14 bg-white/6'
: 'border-brand-dark/12 bg-white/72',
)}
>
<div
className={cn(
'h-32 rounded-xl border border-dashed',
cinematic
? 'border-white/18 bg-[linear-gradient(180deg,rgba(148,163,184,0.14),rgba(148,163,184,0.02))]'
: 'border-brand-dark/16 bg-[linear-gradient(180deg,rgba(148,163,184,0.15),rgba(148,163,184,0.04))]',
)}
/>
<p className={cn('mt-2 text-[11px]', cinematic ? 'text-white/54' : 'text-brand-dark/52')}>
</p>
</section>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useMemo, useState, type ReactNode } from 'react';
import { import {
getHubRoomSections, getHubRoomSections,
getRoomCardBackgroundStyle, getRoomCardBackgroundStyle,
@@ -28,7 +28,12 @@ export const RoomsGalleryWidget = ({
const [carouselOffset, setCarouselOffset] = useState(0); const [carouselOffset, setCarouselOffset] = useState(0);
const cinematic = visualMode === 'cinematic'; const cinematic = visualMode === 'cinematic';
const selectedRoom = rooms.find((room) => room.id === selectedRoomId) ?? rooms[0]; const selectedRoom = rooms.find((room) => room.id === selectedRoomId) ?? rooms[0];
const { recommendedRooms, allRooms } = getHubRoomSections(rooms, selectedRoom.id, 6); const stableRecommendedBaseId = rooms[0]?.id ?? selectedRoom.id;
const { recommendedRooms, allRooms } = getHubRoomSections(
rooms,
stableRecommendedBaseId,
6,
);
const canRotate = recommendedRooms.length > 1; const canRotate = recommendedRooms.length > 1;
const rotatedRecommendedRooms = useMemo(() => { const rotatedRecommendedRooms = useMemo(() => {
if (recommendedRooms.length === 0) { if (recommendedRooms.length === 0) {
@@ -45,10 +50,6 @@ export const RoomsGalleryWidget = ({
]; ];
}, [carouselOffset, recommendedRooms]); }, [carouselOffset, recommendedRooms]);
useEffect(() => {
setCarouselOffset(0);
}, [selectedRoomId]);
return ( return (
<section className="space-y-3"> <section className="space-y-3">
<div <div
@@ -101,12 +102,14 @@ export const RoomsGalleryWidget = ({
</header> </header>
<div className="mt-auto space-y-4"> <div className="mt-auto space-y-4">
<div className="max-w-[560px] rounded-2xl border border-white/16 bg-slate-950/46 px-4 py-3 backdrop-blur-md sm:px-5 sm:py-4"> <div className="max-w-[460px] rounded-xl border border-white/12 bg-slate-950/34 px-3.5 py-2 backdrop-blur-sm sm:px-4 sm:py-2.5">
<p className="text-[11px] uppercase tracking-[0.18em] text-white/56">Selected Space</p> <p className="text-[11px] uppercase tracking-[0.18em] text-white/56">Selected Space</p>
<h3 className="mt-1 text-3xl font-semibold tracking-tight text-white sm:text-[2.25rem]"> <h3 className="mt-0.5 text-[1.65rem] font-semibold tracking-tight text-white sm:text-[1.75rem]">
{selectedRoom.name} {selectedRoom.name}
</h3> </h3>
<p className="mt-1.5 text-sm text-white/78 sm:text-base">{selectedRoom.description}</p> <p className="mt-0.5 text-sm text-white/74 truncate" title={selectedRoom.description}>
{selectedRoom.description}
</p>
</div> </div>
<div className="grid gap-3 lg:grid-cols-[minmax(0,360px)_minmax(0,1fr)] lg:items-end"> <div className="grid gap-3 lg:grid-cols-[minmax(0,360px)_minmax(0,1fr)] lg:items-end">

View File

@@ -36,7 +36,7 @@ export const StartRitualWidget = ({
inStage ? 'space-y-3 p-3.5 sm:p-4' : 'space-y-3.5 p-4 sm:p-5', inStage ? 'space-y-3 p-3.5 sm:p-4' : 'space-y-3.5 p-4 sm:p-5',
cinematic cinematic
? inStage ? inStage
? 'border-white/18 bg-slate-950/62 text-white shadow-[0_18px_36px_rgba(2,6,23,0.36)] backdrop-blur-xl' ? 'border-white/24 bg-slate-950/72 text-white shadow-[0_20px_44px_rgba(2,6,23,0.44),inset_0_1px_0_rgba(255,255,255,0.04)] backdrop-blur-xl'
: 'border-white/16 bg-slate-950/48 text-white shadow-[0_20px_44px_rgba(2,6,23,0.36)] backdrop-blur-xl' : 'border-white/16 bg-slate-950/48 text-white shadow-[0_20px_44px_rgba(2,6,23,0.36)] backdrop-blur-xl'
: 'border-brand-dark/10 bg-white/80 text-brand-dark shadow-[0_16px_40px_rgba(15,23,42,0.12)] backdrop-blur-md', : 'border-brand-dark/10 bg-white/80 text-brand-dark shadow-[0_16px_40px_rgba(15,23,42,0.12)] backdrop-blur-md',
)} )}
@@ -99,8 +99,8 @@ export const StartRitualWidget = ({
<div <div
className={cn( className={cn(
'flex flex-col gap-2.5 sm:flex-row sm:items-center', 'flex flex-col items-stretch gap-2.5',
inStage ? 'sm:justify-start' : 'sm:justify-end', inStage ? 'sm:items-stretch' : 'sm:items-end',
)} )}
> >
<Button <Button
@@ -120,11 +120,11 @@ export const StartRitualWidget = ({
onClick={onOpenCustomEntry} onClick={onOpenCustomEntry}
className={cn( className={cn(
inStage inStage
? 'inline-flex h-10 w-full items-center justify-center gap-1.5 rounded-xl px-3.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/75 sm:w-auto sm:min-w-[118px]' ? 'inline-flex h-9 items-center justify-center gap-1 rounded-lg px-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/75 self-end'
: 'inline-flex h-11 w-full items-center justify-center gap-1.5 rounded-xl px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/75 sm:h-10 sm:w-auto sm:min-w-[122px] sm:px-3.5', : 'inline-flex h-9 items-center justify-center gap-1 rounded-lg px-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/75 self-end',
cinematic cinematic
? 'text-white/70 hover:bg-white/8 hover:text-white' ? 'text-white/66 hover:text-white'
: 'text-brand-dark/64 hover:bg-brand-dark/5 hover:text-brand-dark', : 'text-brand-dark/62 hover:text-brand-dark',
)} )}
> >
<span aria-hidden></span> <span aria-hidden></span>

View File

@@ -23,17 +23,17 @@ export const ThoughtSummaryEntryWidget = ({
type="button" type="button"
onClick={onOpen} onClick={onOpen}
className={cn( className={cn(
'group inline-flex h-10 items-center gap-2 rounded-full border px-3 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/75', 'group inline-flex h-10 items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/75',
cinematic cinematic
? 'border-white/22 bg-white/10 text-white/86 hover:bg-white/16' ? 'border-white/18 bg-white/8 text-white/74 hover:bg-white/14'
: 'border-brand-dark/14 bg-white/72 text-brand-dark/80 hover:bg-white/90', : 'border-brand-dark/12 bg-white/66 text-brand-dark/68 hover:bg-white/82',
)} )}
> >
<span aria-hidden className="text-sm"></span> <span aria-hidden className="text-sm"></span>
<span></span> <span className="sr-only sm:not-sr-only sm:inline"></span>
<span <span
className={cn( className={cn(
'inline-flex min-w-[1.25rem] items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold', 'inline-flex min-w-[1rem] items-center justify-center rounded-full px-1 py-0.5 text-[9px] font-semibold',
cinematic cinematic
? 'bg-sky-200/20 text-sky-100' ? 'bg-sky-200/20 text-sky-100'
: 'bg-brand-primary/14 text-brand-primary/86', : 'bg-brand-primary/14 text-brand-primary/86',
@@ -68,17 +68,17 @@ export const ThoughtSummaryEntryWidget = ({
</span> </span>
<div> <div>
<p className={cn('text-sm font-semibold', cinematic ? 'text-white' : 'text-brand-dark')}> <p className={cn('text-sm font-semibold', cinematic ? 'text-white' : 'text-brand-dark')}>
</p> </p>
<p className={cn('text-xs', cinematic ? 'text-white/56' : 'text-brand-dark/52')}> <p className={cn('text-xs', cinematic ? 'text-white/56' : 'text-brand-dark/52')}>
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
className={cn( className={cn(
'inline-flex min-w-[1.7rem] items-center justify-center rounded-full px-2 py-1 text-[11px] font-semibold', 'inline-flex min-w-[1.4rem] items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold',
cinematic cinematic
? 'bg-sky-200/20 text-sky-100' ? 'bg-sky-200/20 text-sky-100'
: 'bg-brand-primary/14 text-brand-primary/86', : 'bg-brand-primary/14 text-brand-primary/86',
@@ -86,8 +86,8 @@ export const ThoughtSummaryEntryWidget = ({
> >
{countLabel} {countLabel}
</span> </span>
<span className={cn('text-xs transition-transform group-hover:translate-x-0.5', cinematic ? 'text-white/58' : 'text-brand-dark/52')}> <span className={cn('text-xs', cinematic ? 'text-white/58' : 'text-brand-dark/52')}>
</span> </span>
</div> </div>
</button> </button>