diff --git a/docs/foundation/08_premium_uiux_guideline.md b/docs/foundation/08_premium_uiux_guideline.md index 94e987b..9cdaf01 100644 --- a/docs/foundation/08_premium_uiux_guideline.md +++ b/docs/foundation/08_premium_uiux_guideline.md @@ -21,6 +21,11 @@ - **필수 속성:** `backdrop-blur-xl` 또는 `backdrop-blur-2xl`을 반드시 사용합니다. - **테두리와 명암:** 투박한 solid border 대신, `border-white/5` ~ `border-white/10` 수준의 매우 얇고 투명한 테두리를 사용합니다. 깊이감을 위해 다중 그림자(`shadow-2xl` 등)와 미세한 그라데이션(`bg-[linear-gradient(...)]`)을 조합합니다. +### 1-4. 무대와 대기실의 분리 (Stage vs. Lobby Separation) +- **세션(Space) 외의 화면(`/app`, `/stats`, `/settings` 등)은 실제 집중 배경(Atmosphere)을 그대로 띄우지 않습니다.** +- 대기실(Lobby) 역할을 하는 화면에 너무 구체적인 풍경이나 영상이 띄워져 있으면, 사용자가 이미 집중 세션에 들어왔다고 착각하거나 인지적 피로감을 느낄 수 있습니다. +- **해결책:** 대기실 화면의 배경은 집중할 때 볼 풍경을 블러 처리(`blur-3xl`)하거나, 매우 어둡고 깊이 있는 추상적 그라데이션(예: `bg-black`에 은은한 틴트)으로 처리하여 **"아직 무대에 오르기 전(또는 내려온 후)"** 이라는 심리적 분리감을 명확히 주어야 합니다. + --- ## 2. 레이아웃 & 컴포넌트 배치 원칙 (Layout & Placement) diff --git a/docs/screens/app/current/19_app_atmosphere_entry_spec.md b/docs/screens/app/current/19_app_atmosphere_entry_spec.md index 2333405..f52ac3b 100644 --- a/docs/screens/app/current/19_app_atmosphere_entry_spec.md +++ b/docs/screens/app/current/19_app_atmosphere_entry_spec.md @@ -210,7 +210,7 @@ Last Updated: 2026-03-16 - 필수 - 숫자만 - 권장 범위: - - 최소 10분 + - 최소 5분 - 최대 180분 - helper: - `이 목표를 끝내는 데 걸릴 것 같은 시간을 적어요.` diff --git a/src/widgets/focus-dashboard/model/atmosphereEntry.ts b/src/widgets/focus-dashboard/model/atmosphereEntry.ts index 82166dc..083fc61 100644 --- a/src/widgets/focus-dashboard/model/atmosphereEntry.ts +++ b/src/widgets/focus-dashboard/model/atmosphereEntry.ts @@ -158,11 +158,15 @@ export const parseDurationMinutes = (value: string) => { return null; } - return Math.max(10, Math.min(180, parsed)); + if (parsed < 5) { + return null; + } + + return Math.min(180, parsed); }; export const sanitizeDurationDraft = (value: string) => { - const digitsOnly = value.replace(/[^\d]/g, ''); + const digitsOnly = value.replace(/[^\d]/g, '').slice(0, 3); if (!digitsOnly) { return ''; } @@ -172,7 +176,7 @@ export const sanitizeDurationDraft = (value: string) => { return ''; } - return String(Math.max(10, Math.min(180, parsed))); + return String(Math.min(180, parsed)); }; export const getTimerPresetMetaById = (timerPresetId: string) => { diff --git a/src/widgets/focus-dashboard/ui/AppAtmosphereEntryShell.tsx b/src/widgets/focus-dashboard/ui/AppAtmosphereEntryShell.tsx index 8c4daca..7a5c7b9 100644 --- a/src/widgets/focus-dashboard/ui/AppAtmosphereEntryShell.tsx +++ b/src/widgets/focus-dashboard/ui/AppAtmosphereEntryShell.tsx @@ -57,13 +57,13 @@ export const AppAtmosphereEntryShell = ({ {/* Main Focus Entry Ritual */}
{/* Inline Accessories (No overlap guarantee) */} -
+
{errorAccessory} {!errorAccessory && topAccessory}
-
-

+

+

What will you focus on?

-
-
-
-
- {durationSuggestions.map((minutes) => { - const isSelected = durationDraft === String(minutes); - return ( - - ); - })} -
-
-
- onDurationChange(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault(); - onStartSession(); - } - }} - inputMode="numeric" - placeholder="Custom" - className="w-16 bg-transparent text-right text-lg font-medium text-white outline-none placeholder:text-white/30" - /> - min -
+
+ +
+ {/* Primary Action: Massive Custom Timer Input */} +
+ onDurationChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + onStartSession(); + } + }} + inputMode="numeric" + placeholder="0" + className="w-24 bg-transparent text-center text-6xl font-light tracking-tighter text-white outline-none placeholder:text-white/10 transition-all focus:w-32 md:text-7xl" + /> + + min +
-

{durationHelper}

+ + {/* Secondary Action: Subtle Quick Select Pills */} +
+ {durationSuggestions.map((minutes) => { + const isSelected = durationDraft === String(minutes); + return ( + + ); + })} +
+ +

{durationHelper}

diff --git a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx index a4cf99c..3939fab 100644 --- a/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx +++ b/src/widgets/focus-dashboard/ui/FocusDashboardWidget.tsx @@ -140,6 +140,15 @@ export const FocusDashboardWidget = () => { () => getAtmosphereOptionById(selectedAtmosphereId), [selectedAtmosphereId], ); + const rawDurationValue = useMemo(() => { + const digitsOnly = durationDraft.replace(/[^\d]/g, ''); + if (!digitsOnly) { + return null; + } + + const parsed = Number(digitsOnly); + return Number.isFinite(parsed) ? parsed : null; + }, [durationDraft]); const parsedDurationMinutes = parseDurationMinutes(durationDraft); const resolvedTimerPreset = useMemo(() => { const targetMinutes = @@ -178,9 +187,11 @@ export const FocusDashboardWidget = () => { isPro && reviewEntryPresetConfig ? `추천 ritual · ${reviewEntryPresetConfig.label}` : null; const reviewTeaserTitle = isPro ? entryCopy.reviewTitlePro : entryCopy.reviewTitle; const durationHelper = - parsedDurationMinutes === null - ? 'Please enter the estimated duration in minutes.' - : entryCopy.durationHelper; + rawDurationValue !== null && rawDurationValue < 5 + ? 'Please enter at least 5 minutes.' + : parsedDurationMinutes === null + ? 'Please enter the estimated duration in minutes.' + : entryCopy.durationHelper; const hasCurrentSession = Boolean(currentSession); useEffect(() => { @@ -301,13 +312,13 @@ export const FocusDashboardWidget = () => {
{/* Immersive Overlay Gradients */} -
-
+
+
{/* Header */}
diff --git a/src/widgets/stats-overview/ui/StatsOverviewWidget.tsx b/src/widgets/stats-overview/ui/StatsOverviewWidget.tsx index 8cb0518..0b17001 100644 --- a/src/widgets/stats-overview/ui/StatsOverviewWidget.tsx +++ b/src/widgets/stats-overview/ui/StatsOverviewWidget.tsx @@ -9,11 +9,6 @@ import { useFocusStats } from '@/features/stats'; import { copy } from '@/shared/i18n'; import { cn } from '@/shared/lib/cn'; -const panelClass = - 'relative overflow-hidden rounded-[2rem] border border-white/10 bg-[linear-gradient(160deg,rgba(8,12,18,0.46)_0%,rgba(8,12,18,0.2)_58%,rgba(8,12,18,0.52)_100%)] shadow-[0_24px_80px_rgba(3,7,18,0.28)] backdrop-blur-[24px]'; -const innerTileClass = - 'rounded-[1.4rem] border border-white/10 bg-[linear-gradient(145deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.04)_100%)] px-4 py-4 backdrop-blur-xl'; - const DEFAULT_STATS_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id; const reviewStageSceneByPreset = (presetId: string) => { @@ -24,138 +19,6 @@ const reviewStageSceneByPreset = (presetId: string) => { return getSceneById(DEFAULT_STATS_SCENE_ID) ?? SCENE_THEMES[0]; }; -const AccessoryPill = ({ - label, - subtle = false, -}: { - label: string; - subtle?: boolean; -}) => { - return ( - - - {label} - - ); -}; - -const SnapshotCell = ({ - label, - value, - hint, -}: { - label: string; - value: string; - hint: string; -}) => { - return ( -
-

{label}

-

{value}

-

{hint}

-
- ); -}; - -const InsightBoard = ({ - title, - summary, - metrics, - availability, - note, - accentClass, -}: { - title: string; - summary: string; - metrics: Array<{ id: string; label: string; value: string; hint: string }>; - availability: 'ready' | 'limited'; - note?: string; - accentClass: string; -}) => { - const heroMetric = metrics[0]; - const supportMetrics = metrics.slice(1); - - return ( -
-
- -
-
-
-

- Review Signal -

-

- {title} -

-

{summary}

-
- {availability === 'limited' ? : null} -
- -
-
- {heroMetric ? ( - <> -

- {heroMetric.label} -

-
-

- {heroMetric.value} -

-
-

- {heroMetric.hint} -

- - ) : ( -

아직 보여줄 지표가 충분하지 않아요.

- )} -
- -
- {supportMetrics.length > 0 ? ( - supportMetrics.map((metric) => ( -
-
-
-

- {metric.label} -

-

- {metric.value} -

-
-
-

{metric.hint}

-
- )) - ) : ( -
-

보조 지표는 데이터가 쌓이면 함께 보입니다.

-
- )} -
-
- - {note ? ( -

- {note} -

- ) : null} -
-
- ); -}; - export const StatsOverviewWidget = () => { const { stats } = copy; const { isPro } = usePlanTier(); @@ -171,181 +34,214 @@ export const StatsOverviewWidget = () => { const carryForwardCtaLabel = isPro ? stats.reviewCarryCtaPro : review.carryForward.ctaLabel; return ( -
+
+ {/* Immersive Background */}
-
-
-
-
+ {/* Premium Cinematic Overlays */} +
+
+
-
+ {/* Header */} +
-

+

Weekly Review

- {isPro ? ( - + {isPro && ( + PRO - ) : null} + )}
-
+
- {copy.common.hub} + {copy.common.hub} +
-
-
-
-
- -
-
- - -
- -
-

- 집중 리듬 요약 -

-

- {review.snapshotSummary} -

-

- {syncLabel} -

-
+ {/* Main Flow */} +
+ + {/* 1. Hero Summary (Cinematic Entry) */} +
+
+
+ + + {review.periodLabel} + +
+
+ + {sourceLabel} +
+ +

+ {review.snapshotSummary} +

+

+ {syncLabel} +

-
-
-
-

- Snapshot Signals -

-
- {review.snapshotMetrics.map((metric) => ( - - ))} + {/* Inline Snapshot Metrics */} +
+ {review.snapshotMetrics.map((metric) => ( +
+

+ {metric.label} +

+

+ {metric.value} +

+

+ {metric.hint} +

-
+ ))}
-
- - - -
- -
- - -
-
- -
-
-
-

- Carry Forward -

-

- 다음 세션에 그대로 가져갈 흐름 -

-

- {review.carryForward.keepDoing} -

+ {/* 2. Insight Pillars (Start, Recovery, Completion) */} +
+ {[ + { + data: review.startQuality, + accent: 'bg-[radial-gradient(circle_at_top,rgba(96,165,250,0.15),transparent_70%)]' + }, + { + data: review.recoveryQuality, + accent: 'bg-[radial-gradient(circle_at_top,rgba(20,184,166,0.15),transparent_70%)]' + }, + { + data: review.completionQuality, + accent: 'bg-[radial-gradient(circle_at_top,rgba(245,158,11,0.15),transparent_70%)]' + } + ].map((column, idx) => ( +
+
+
+

+ {column.data.title} +

+

+ {column.data.summary} +

+ +
+ {column.data.metrics.map((metric, mIdx) => ( +
0 && "pt-6 border-t border-white/5")}> +

+ {metric.label} +

+

+ {metric.value} +

+

+ {metric.hint} +

+
+ ))} + {column.data.metrics.length === 0 && ( +

지표 데이터가 부족합니다.

+ )}
- {isPro ? : null} + {column.data.note && ( +
+

+ {column.data.note} +

+
+ )} + {column.data.availability === 'limited' && ( +
+ Limited Data +
+ )}
+
+ ))} +
-
-
-

- 다음 주에 바꿔볼 것 + {/* 3. Carry Forward Climax */} +

+
+
+
+
+ + + Carry Forward + +
+ +

+ 다음 세션에 가져갈 흐름 +

+

+ "{review.carryForward.keepDoing}" +

+ +
+
+

+ 작은 변화 시도하기

-

+

{review.carryForward.tryNext}

- -
-

- Atmosphere +

+

+ 추천 Atmosphere + {isPro && PRO}

-

+

{review.carryForward.presetLabel}

-

- 가장 무리 없이 다시 들어갈 수 있는 기본 흐름입니다. -

+

가장 무리 없이 들어갈 수 있는 흐름.

-
-

- review는 지난 시간을 예쁘게 요약하는 화면이 아니라, 다음 세션을 더 가볍게 열기 - 위한 출발점이어야 합니다. -

- +
- {carryForwardCtaLabel} + {carryForwardCtaLabel} +

+ Review는 끝이 아니라, 더 가벼운 시작을 위한 출발점입니다. +

-
+
+
);