feat(core-loop): /app 진입과 /space 복구 흐름 구현

This commit is contained in:
2026-03-14 18:02:50 +09:00
parent bc08a049b6
commit b4ed94cf1b
19 changed files with 2638 additions and 619 deletions

View File

@@ -42,14 +42,37 @@ export const space = {
refocusTitle: '다시 방향 잡기',
refocusDescription: '딱 한 줄만 다듬고 다시 시작해요.',
refocusApply: '적용',
refocusApplyAndResume: '적용하고 이어가기',
refocusApplying: '적용 중…',
refocusSaved: '이번 세션 방향을 다듬었어요.',
refocusOpenOnPause: '잠시 멈춘 김에 다음 한 조각을 다시 맞춰볼까요?',
pausePromptEyebrow: '잠깐 멈춤',
pausePromptTitle: '다시 붙잡을 한 조각만 정하면 돼요.',
pausePromptDescription: '왜 멈췄는지는 묻지 않아요. 다시 시작할 한 조각만 남겨요.',
pausePromptRefocus: '한 조각 다시 잡기',
pausePromptRefocusHint: '목표는 그대로 두고, 지금 다시 시작할 한 줄만 정리해요.',
pausePromptKeep: '이대로 이어가기',
pausePromptKeepHint: '지금 방향을 그대로 유지한 채 바로 이어서 시작해요.',
returnPromptEyebrow: '다시 돌아왔어요',
returnPromptFocusTitle: '이어서 갈까요?',
returnPromptFocusDescription: '흐름은 그대로 남아 있어요. 바로 이어가거나 한 조각만 다시 잡을 수 있어요.',
returnPromptBreakTitle: '자리를 비운 사이 이 블록이 끝났어요.',
returnPromptBreakDescription: '지금부터 쉬거나, 다음으로 이어갈 수 있어요.',
returnPromptContinue: '이어서 하기',
returnPromptContinueHint: '타이머와 흐름을 그대로 둔 채 다시 집중으로 돌아갑니다.',
returnPromptRest: '지금부터 쉬기',
returnPromptRestHint: '지금부터 break를 시작한 것처럼 천천히 숨을 고릅니다.',
returnPromptNext: '다음 목표 이어가기',
returnPromptNextHint: '다음 한 조각을 바로 정하고 흐름을 끊지 않고 잇습니다.',
returnPromptRefocus: '한 조각 다시 잡기',
returnPromptRefocusHint: '왜 멈췄는지는 건너뛰고, 지금 다시 시작할 한 줄만 남깁니다.',
microStepCompleteAriaLabel: '현재 한 조각 완료',
microStepPromptTitle: '다음 한 조각이 있나요?',
microStepPromptDescription: '리스트를 만들지 않고, 지금 다시 시작할 한 조각만 정해요.',
microStepPromptKeep: '이 목표만 유지',
microStepPromptKeepHint: '다음 한 조각은 비워두고, 같은 목표만 유지해요.',
microStepPromptDefine: '한 조각 정하기',
microStepPromptDefineHint: '바로 손을 움직일 수 있는 다음 한 줄만 정해요.',
microStepCleared: '지금 할 한 조각을 비우고 목표만 유지해요.',
completeAction: '이번 목표 완료',
},
@@ -59,10 +82,19 @@ export const space = {
placeholderExample: (goal: string) => `예: ${goal}`,
title: '좋아요. 다음 한 조각은?',
description: '너무 크게 잡지 말고, 바로 다음 한 조각만.',
currentGoalLabel: '끝낸 목표',
nextGoalLabel: '다음 목표',
chooseNextButton: '다음 목표 이어가기',
chooseNextDescription: '바로 이어갈 다음 한 조각을 정하고 계속 갑니다.',
backButton: '돌아가기',
closeAriaLabel: '닫기',
finishButton: '여기까지 끝내기',
finishDescription: '이 블록은 여기서 닫고, 다음 진입은 가볍게 남겨둡니다.',
restButton: '잠깐 쉬기',
restDescription: '지금은 멈추고 숨을 고른 뒤 돌아올 여지를 남겨둡니다.',
confirmButton: '다음 목표로 바로 시작',
confirmPending: '시작 중…',
finishPending: '마무리 중…',
},
controlCenter: {
sectionTitles: {

View File

@@ -1,566 +1,346 @@
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
type FocusPlanItem,
type FocusPlanToday,
useFocusPlan,
} from '@/entities/focus-plan';
import { usePlanTier } from '@/entities/plan';
import { SCENE_THEMES, getSceneById } from '@/entities/scene';
import { useRouter } from 'next/navigation';
import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media';
import { usePlanTier } from '@/entities/plan';
import { getSceneById, SCENE_THEMES } from '@/entities/scene';
import { SOUND_PRESETS } from '@/entities/session';
import { PaywallSheetContent } from '@/features/paywall-sheet';
import { PlanPill } from '@/features/plan-pill';
import { focusSessionApi } from '@/features/focus-session/api/focusSessionApi';
import { focusSessionApi, type FocusSession } from '@/features/focus-session/api/focusSessionApi';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
import { useDragScroll } from '@/shared/lib/useDragScroll';
import { FocusPlanManageSheet, type FocusPlanEditingState } from './FocusPlanManageSheet';
const FREE_MAX_ITEMS = 1;
const PRO_MAX_ITEMS = 5;
const DEFAULT_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id;
const DEFAULT_SOUND_ID = SOUND_PRESETS.find((preset) => preset.id === 'forest-birds')?.id ?? SOUND_PRESETS[0].id;
const DEFAULT_TIMER_ID = '50-10';
const GOAL_SUGGESTIONS = copy.session.goalChips.slice(0, 4);
const focusEntryCopy = {
const entryCopy = {
eyebrow: 'VibeRoom',
title: '오늘의 깊은 몰입단 하나의 목표',
description: '지금 당장 시작할 딱 하나만 남겨두세요.',
inputLabel: '첫 블록',
inputPlaceholder: '예: 제안서 첫 문단만 다듬기',
helper: '아주 작게 잡아도 괜찮아요.',
startNow: '바로 몰입하기',
nextStep: '환경 세팅',
manageBlocks: '내 계획에서 가져오기',
previewTitle: '이어갈 블록',
previewDescription: '다음 후보는 가볍게만 두고, 시작은 위 버튼 하나로 끝냅니다.',
reviewLinkLabel: 'stats',
reviewFallback: '최근 7일 흐름을 불러오는 중이에요.',
ritualMeta: '기본 설정으로 들어갑니다. 공간 안에서 언제든 바꿀 수 있어요.',
apiUnavailableNote: '계획 연결이 잠시 느려요. 지금은 첫 블록부터 바로 시작할 수 있어요.',
freeUpgradeLabel: '두 번째 블록부터는 PRO',
paywallSource: 'focus-entry-manage-sheet',
title: '지금 붙잡을 한 가지',
description: '길게 정리하지 말고, 한 줄만 남기고 바로 들어가요.',
goalPlaceholder: '예: 제안서 첫 문단만 다듬기',
microStepLabel: '지금 할 한 조각',
microStepPlaceholder: '예: 파일 열고 첫 문장만 정리하기',
microStepHelper: '선택 사항이에요. 바로 손이 가게 만드는 한 조각이면 충분해요.',
startNow: '지금 시작',
startLoading: '몰입 준비 중...',
ritualHint: '기본 ritual · 숲 · 50/10 · Forest Birds',
ritualHelper: '공간과 사운드는 들어간 뒤에도 바꿀 수 있어요.',
resumeEyebrow: 'Resume',
resumeRunning: '진행 중인 세션이 있어요.',
resumePaused: '잠시 멈춘 세션이 있어요.',
resumeCta: '이어서 들어가기',
resumeMicroStepLabel: '마지막 한 조각',
resumeNewGoalHint: '새 목표는 현재 세션을 마무리한 뒤 시작할 수 있어요.',
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
paywallLead: 'Calm Session OS PRO',
paywallBody: '여러 블록을 이어서 정리하는 manage sheet는 PRO에서 열립니다.',
microStepTitle: '가장 작은 첫 단계 (선택)',
microStepHelper: '이 목표를 위해 당장 할 수 있는 5분짜리 행동은 무엇인가요?',
microStepPlaceholder: '예: 폴더 열기, 노션 켜기',
ritualTitle: '어떤 환경에서 몰입하시겠어요?',
ritualHelper: '오늘의 무드를 선택하세요.',
paywallBody: 'Pro는 더 빠른 ritual과 더 깊은 review로 시작과 복귀를 가볍게 만듭니다.',
};
const ENTRY_SUGGESTIONS = [
{ id: 'tidy-10m', label: '정리 10분', goal: '정리 10분만 하기' },
{ id: 'mail-3', label: '메일 3개', goal: '메일 3개 정리' },
{ id: 'doc-1p', label: '문서 1p', goal: '문서 1p 다듬기' },
] as const;
const goalCardClass =
'w-full rounded-[2rem] border border-white/12 bg-[#0f1115]/26 px-6 py-6 shadow-[0_24px_60px_rgba(3,7,18,0.32)] backdrop-blur-xl md:px-8 md:py-8';
const inputShellClass =
'w-full rounded-[1.75rem] border border-white/14 bg-white/[0.06] px-5 py-4 text-white outline-none transition focus:border-white/24 focus:bg-white/[0.09]';
const primaryButtonClass =
'inline-flex items-center justify-center rounded-full border border-white/16 bg-white/[0.14] px-6 py-3 text-sm font-medium text-white transition hover:bg-white/[0.18] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-48';
type EntrySource = 'starter' | 'plan' | 'custom';
type DashboardStep = 'goal' | 'ritual';
const getVisiblePlanItems = (
currentItem: FocusPlanItem | null,
nextItems: FocusPlanItem[],
limit: number,
) => {
return [currentItem, ...nextItems]
.filter((item): item is FocusPlanItem => Boolean(item))
.slice(0, limit);
const timerLabelById: Record<string, string> = {
'25-5': '25/5',
'50-10': '50/10',
'90-20': '90/20',
};
const resolveVisiblePlanItems = (nextPlan: FocusPlanToday | null, limit: number) => {
return getVisiblePlanItems(nextPlan?.currentItem ?? null, nextPlan?.nextItems ?? [], limit);
};
const resolveSoundLabel = (soundPresetId?: string | null) => {
if (!soundPresetId) {
return 'Silent';
}
// Premium Glassmorphism UI Classes
const glassInputClass = 'w-full rounded-full border border-white/20 bg-black/20 px-8 py-5 text-center text-lg md:text-xl font-light tracking-wide text-white placeholder:text-white/40 shadow-2xl backdrop-blur-xl outline-none transition-all focus:border-white/40 focus:bg-black/30 focus:ring-4 focus:ring-white/10';
const primaryGlassBtnClass = 'inline-flex items-center justify-center rounded-full border border-white/20 bg-white/20 px-8 py-4 text-base font-medium text-white shadow-xl backdrop-blur-xl transition-all hover:bg-white/30 hover:scale-[1.02] active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed';
const secondaryGlassBtnClass = 'inline-flex items-center justify-center rounded-full border border-white/10 bg-transparent px-8 py-4 text-base font-medium text-white/80 transition-all hover:bg-white/10 hover:text-white active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed';
const panelGlassClass = 'rounded-[2rem] border border-white/10 bg-black/40 p-6 md:p-8 shadow-2xl backdrop-blur-2xl';
const itemCardGlassClass = 'relative flex flex-col items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-4 text-white transition-all hover:bg-white/10 active:scale-95 cursor-pointer';
const itemCardGlassSelectedClass = 'border-white/40 bg-white/20 shadow-[0_0_20px_rgba(255,255,255,0.1)]';
return SOUND_PRESETS.find((preset) => preset.id === soundPresetId)?.label ?? 'Silent';
};
export const FocusDashboardWidget = () => {
const router = useRouter();
const { plan: planTier, isPro, setPlan } = usePlanTier();
const { plan, isLoading, isSaving, source, createItem, updateItem, deleteItem } = useFocusPlan();
const { plan, isPro, setPlan } = usePlanTier();
const { sceneAssetMap } = useMediaCatalog();
const [step, setStep] = useState<DashboardStep>('goal');
const [paywallSource, setPaywallSource] = useState<string | null>(null);
const [manageSheetOpen, setManageSheetOpen] = useState(false);
const [editingState, setEditingState] = useState<FocusPlanEditingState>(null);
const [entryDraft, setEntryDraft] = useState('');
const [selectedPlanItemId, setSelectedPlanItemId] = useState<string | null>(null);
const [entrySource, setEntrySource] = useState<EntrySource>('starter');
const [goalDraft, setGoalDraft] = useState('');
const [microStepDraft, setMicroStepDraft] = useState('');
// Use user's last preference or default to first
const [selectedSceneId, setSelectedSceneId] = useState(SCENE_THEMES[0].id);
const [selectedSoundId, setSelectedSoundId] = useState(SOUND_PRESETS[0].id);
const [selectedTimerId, setSelectedTimerId] = useState('50-10');
const [isStartingSession, setIsStartingSession] = useState(false);
const [paywallSource, setPaywallSource] = useState<string | null>(null);
const [currentSession, setCurrentSession] = useState<FocusSession | null>(null);
const [isCheckingSession, setIsCheckingSession] = useState(true);
const [sessionLookupError, setSessionLookupError] = useState<string | null>(null);
const entryInputRef = useRef<HTMLInputElement | null>(null);
const microStepInputRef = useRef<HTMLInputElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const goalInputRef = useRef<HTMLInputElement | null>(null);
const {
containerRef: sceneContainerRef,
events: sceneDragEvents,
isDragging: isSceneDragging,
shouldSuppressClick: shouldSuppressSceneClick,
} = useDragScroll();
const activeScene = useMemo(() => {
return getSceneById(currentSession?.sceneId ?? DEFAULT_SCENE_ID) ?? SCENE_THEMES[0];
}, [currentSession?.sceneId]);
const selectedScene = useMemo(() => getSceneById(selectedSceneId) ?? SCENE_THEMES[0], [selectedSceneId]);
const activeRitualMeta = useMemo(() => {
const timerLabel = timerLabelById[currentSession?.timerPresetId ?? DEFAULT_TIMER_ID] ?? '50/10';
const soundLabel = resolveSoundLabel(currentSession?.soundPresetId ?? DEFAULT_SOUND_ID);
const maxItems = isPro ? PRO_MAX_ITEMS : FREE_MAX_ITEMS;
const planItems = useMemo(() => {
return getVisiblePlanItems(plan.currentItem, plan.nextItems, maxItems);
}, [maxItems, plan.currentItem, plan.nextItems]);
return `${activeScene.name} · ${timerLabel} · ${soundLabel}`;
}, [activeScene.name, currentSession?.soundPresetId, currentSession?.timerPresetId]);
const currentItem = planItems[0] ?? null;
const shouldUseCurrentPlanDefaults =
Boolean(currentItem) && (entrySource === 'starter' || (entrySource === 'plan' && !selectedPlanItemId));
const resolvedEntryDraft = shouldUseCurrentPlanDefaults && currentItem ? currentItem.title : entryDraft;
const resolvedSelectedPlanItemId =
shouldUseCurrentPlanDefaults && currentItem ? currentItem.id : selectedPlanItemId;
const hasPendingEdit = editingState !== null;
const canAddMore = planItems.length < maxItems;
const canManagePlan = source === 'api' && !isLoading;
const trimmedEntryGoal = resolvedEntryDraft.trim();
const isGoalReady = trimmedEntryGoal.length > 0;
const trimmedGoal = goalDraft.trim();
const canStart = trimmedGoal.length > 0 && !isStartingSession && !currentSession;
useEffect(() => {
if (!editingState) return;
const rafId = window.requestAnimationFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
return () => window.cancelAnimationFrame(rafId);
}, [editingState]);
let cancelled = false;
const openPaywall = () => setPaywallSource(focusEntryCopy.paywallSource);
const loadCurrentSession = async () => {
setIsCheckingSession(true);
const handleSelectPlanItem = (item: FocusPlanItem) => {
const isCurrentSelection = currentItem?.id === item.id;
setEntryDraft(item.title);
setSelectedPlanItemId(isCurrentSelection ? item.id : null);
setEntrySource(isCurrentSelection ? 'plan' : 'custom');
setManageSheetOpen(false);
};
const handleSelectSuggestion = (goal: string) => {
setEntryDraft(goal);
setSelectedPlanItemId(null);
setEntrySource('custom');
};
const handleEntryDraftChange = (value: string) => {
setEntryDraft(value);
setEntrySource('custom');
setSelectedPlanItemId(null);
};
const handleAddBlock = () => {
if (hasPendingEdit || isSaving || !canManagePlan) return;
if (!canAddMore) {
if (!isPro) openPaywall();
return;
}
setEditingState({ mode: 'new', value: '' });
};
const handleEditRow = (item: FocusPlanItem) => {
if (hasPendingEdit || isSaving) return;
setEditingState({ mode: 'edit', itemId: item.id, value: item.title });
};
const handleManageDraftChange = (value: string) => {
setEditingState((current) => current ? { ...current, value } : current);
};
const handleCancelEdit = () => {
if (!isSaving) setEditingState(null);
};
const handleSaveEdit = async () => {
if (!editingState) return;
const trimmedTitle = editingState.value.trim();
if (!trimmedTitle) return;
if (editingState.mode === 'new') {
const nextPlan = await createItem({ title: trimmedTitle });
if (!nextPlan) return;
setEditingState(null);
if (!currentItem) {
const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems);
const nextCurrentItem = nextVisiblePlanItems[0] ?? null;
if (nextCurrentItem) {
setEntryDraft(nextCurrentItem.title);
setSelectedPlanItemId(nextCurrentItem.id);
setEntrySource('plan');
try {
const session = await focusSessionApi.getCurrentSession();
if (cancelled) {
return;
}
setCurrentSession(session);
setSessionLookupError(null);
} catch (error) {
if (cancelled) {
return;
}
setCurrentSession(null);
setSessionLookupError(error instanceof Error ? error.message : entryCopy.loadFailed);
} finally {
if (!cancelled) {
setIsCheckingSession(false);
}
}
return;
}
};
const currentRow = planItems.find((item) => item.id === editingState.itemId);
if (!currentRow) return;
if (currentRow.title === trimmedTitle) {
setEditingState(null);
return;
}
void loadCurrentSession();
const nextPlan = await updateItem(editingState.itemId, { title: trimmedTitle });
if (!nextPlan) return;
setEditingState(null);
if (resolvedSelectedPlanItemId === editingState.itemId) {
setEntryDraft(trimmedTitle);
setEntrySource('plan');
return () => {
cancelled = true;
};
}, []);
const openPaywall = () => {
if (!isPro) {
setPaywallSource('app-entry-plan-pill');
}
};
const handleDeleteRow = async (itemId: string) => {
const nextPlan = await deleteItem(itemId);
if (!nextPlan) return;
if (editingState?.mode === 'edit' && editingState.itemId === itemId) {
setEditingState(null);
}
if (resolvedSelectedPlanItemId === itemId) {
const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems);
const nextCurrentItem = nextVisiblePlanItems[0] ?? null;
if (nextCurrentItem) {
setEntryDraft(nextCurrentItem.title);
setSelectedPlanItemId(nextCurrentItem.id);
setEntrySource('plan');
return;
}
setEntryDraft('');
setSelectedPlanItemId(null);
setEntrySource('custom');
}
};
const handleNextStep = () => {
if (!isGoalReady) {
entryInputRef.current?.focus();
return;
}
if (step === 'goal') setStep('ritual');
const handleSelectSuggestion = (label: string) => {
setGoalDraft(label);
goalInputRef.current?.focus();
};
const handleStartSession = async () => {
if (isStartingSession) return;
if (!trimmedGoal || isStartingSession || currentSession) {
if (!trimmedGoal) {
goalInputRef.current?.focus();
}
return;
}
setIsStartingSession(true);
try {
await focusSessionApi.startSession({
goal: trimmedEntryGoal,
goal: trimmedGoal,
microStep: microStepDraft.trim() || null,
sceneId: selectedSceneId,
soundPresetId: selectedSoundId,
timerPresetId: selectedTimerId,
focusPlanItemId: resolvedSelectedPlanItemId || undefined,
entryPoint: 'space-setup'
sceneId: DEFAULT_SCENE_ID,
soundPresetId: DEFAULT_SOUND_ID,
timerPresetId: DEFAULT_TIMER_ID,
entryPoint: 'space-setup',
});
router.push('/space');
} catch (err) {
console.error('Failed to start session', err);
setIsStartingSession(false);
return;
} catch (error) {
console.error('Failed to start focus session from /app', error);
}
setIsStartingSession(false);
};
const handleResumeSession = () => {
router.push('/space');
};
return (
<div className="relative min-h-dvh overflow-hidden bg-slate-900 text-white font-sans selection:bg-white/20">
{/* Premium Cinematic Background */}
<div className="relative min-h-dvh overflow-hidden bg-slate-950 text-white selection:bg-white/20">
<div
className={cn(
"absolute inset-0 bg-cover bg-center transition-all duration-1000 ease-out will-change-transform",
isStartingSession ? 'scale-110 blur-2xl opacity-0' : 'scale-100 opacity-100',
step === 'ritual' ? 'scale-105 blur-sm' : ''
'absolute inset-0 bg-cover bg-center transition-transform duration-700 ease-out',
isStartingSession ? 'scale-[1.04]' : 'scale-100',
)}
style={getSceneStageBackgroundStyle(selectedScene, sceneAssetMap?.[selectedScene.id])}
style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.id])}
/>
{/* Global Gradient Overlay for text readability */}
<div className={cn(
"absolute inset-0 bg-gradient-to-b from-black/20 via-black/40 to-black/60 transition-opacity duration-1000",
step === 'ritual' ? 'opacity-80' : 'opacity-100'
)} />
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.22)_0%,rgba(2,6,23,0.38)_55%,rgba(2,6,23,0.5)_100%)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.06),rgba(255,255,255,0)_42%)]" />
{/* Header */}
<header className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-6 md:p-8">
<p className="text-sm font-semibold tracking-[0.3em] text-white/50 uppercase">
{focusEntryCopy.eyebrow}
<header className="relative z-10 flex items-center justify-between px-5 py-5 md:px-8 md:py-7">
<p className="text-sm font-semibold tracking-[0.28em] text-white/56 uppercase">
{entryCopy.eyebrow}
</p>
<PlanPill
plan={planTier}
onClick={() => {
if (!isPro) openPaywall();
}}
/>
<PlanPill plan={plan} onClick={openPaywall} />
</header>
{/* Main Content Area */}
<main className="relative z-10 flex h-dvh flex-col items-center justify-center px-4">
{/* Step 1: Goal Setup */}
<div className={cn(
"w-full max-w-2xl transition-all duration-700 absolute",
step === 'goal'
? 'opacity-100 translate-y-0 pointer-events-auto'
: 'opacity-0 -translate-y-8 pointer-events-none'
)}>
<div className="flex flex-col items-center space-y-10 text-center">
<h1 className="text-3xl md:text-5xl font-light tracking-tight text-white drop-shadow-lg leading-tight">
{focusEntryCopy.title}
</h1>
<div className="w-full max-w-xl mx-auto space-y-8">
<input
ref={entryInputRef}
value={resolvedEntryDraft}
onChange={(event) => handleEntryDraftChange(event.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleNextStep()}
placeholder={focusEntryCopy.inputPlaceholder}
className={glassInputClass}
autoFocus
/>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button
type="button"
onClick={handleStartSession}
disabled={!isGoalReady || isStartingSession}
className={primaryGlassBtnClass}
>
{focusEntryCopy.startNow}
</button>
<button
type="button"
onClick={handleNextStep}
disabled={!isGoalReady || isStartingSession}
className={secondaryGlassBtnClass}
>
{focusEntryCopy.nextStep}
</button>
</div>
{/* Suggestions / Manage - very minimal */}
<div className="pt-8 flex flex-col items-center gap-4 opacity-70 hover:opacity-100 transition-opacity">
<div className="flex flex-wrap justify-center gap-2">
{ENTRY_SUGGESTIONS.map((suggestion) => {
const isActive = resolvedSelectedPlanItemId === null && trimmedEntryGoal === suggestion.goal;
return (
<button
key={suggestion.id}
type="button"
onClick={() => handleSelectSuggestion(suggestion.goal)}
className={cn(
'rounded-full px-4 py-1.5 text-sm transition-all border',
isActive
? 'bg-white/20 border-white text-white'
: 'bg-transparent border-white/20 text-white/70 hover:border-white/40 hover:text-white'
)}
>
{suggestion.label}
</button>
);
})}
</div>
<button
type="button"
onClick={() => setManageSheetOpen(true)}
disabled={!canManagePlan}
className="text-sm font-medium text-white/50 hover:text-white transition-colors underline underline-offset-4 decoration-white/20"
>
{focusEntryCopy.manageBlocks}
</button>
</div>
<main className="relative z-10 flex min-h-[calc(100dvh-84px)] items-center justify-center px-4 pb-8 pt-4 md:px-6">
<div className="w-full max-w-[42rem]">
{isCheckingSession ? (
<div className={cn(goalCardClass, 'space-y-4 text-center')}>
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
{entryCopy.resumeEyebrow}
</p>
<p className="text-[15px] text-white/72"> .</p>
</div>
</div>
</div>
{/* Step 2: Ritual Setup */}
<div className={cn(
"w-full max-w-4xl transition-all duration-700 absolute",
step === 'ritual'
? 'opacity-100 translate-y-0 pointer-events-auto'
: 'opacity-0 translate-y-8 pointer-events-none'
)}>
<div className={panelGlassClass}>
<div className="flex items-center justify-between mb-8 pb-6 border-b border-white/10">
<div className="flex-1">
<p className="mb-2 text-xs uppercase tracking-widest text-white/40">Today&apos;s Focus</p>
<p className="text-xl md:text-2xl font-light text-white truncate pr-4">{trimmedEntryGoal}</p>
</div>
<button
type="button"
onClick={() => setStep('goal')}
className="rounded-full border border-white/20 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 transition-colors"
>
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12">
<div className="space-y-8">
{/* Microstep */}
<div className="space-y-3">
<label className="block space-y-2">
<span className="text-sm font-medium text-white/80">
{focusEntryCopy.microStepTitle}
</span>
<input
ref={microStepInputRef}
value={microStepDraft}
onChange={(e) => setMicroStepDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleStartSession()}
placeholder={focusEntryCopy.microStepPlaceholder}
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder:text-white/30 outline-none transition-all focus:border-white/30 focus:bg-white/10"
/>
</label>
<p className="text-xs text-white/40">{focusEntryCopy.microStepHelper}</p>
</div>
{/* Timer */}
<div className="space-y-3">
<p className="text-sm font-medium text-white/80"> </p>
<div className="grid grid-cols-3 gap-2">
{[{id: '25-5', label: '25 / 5'}, {id: '50-10', label: '50 / 10'}, {id: '90-20', label: '90 / 20'}].map(timer => (
<button
key={timer.id}
type="button"
onClick={() => setSelectedTimerId(timer.id)}
className={cn(itemCardGlassClass, selectedTimerId === timer.id && itemCardGlassSelectedClass)}
>
<span className="text-sm font-medium">{timer.label}</span>
</button>
))}
) : currentSession ? (
<div className={cn(goalCardClass, 'space-y-5')}>
<div className="space-y-3">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
{entryCopy.resumeEyebrow}
</p>
<h1 className="text-[1.8rem] font-light leading-[1.14] tracking-[-0.03em] text-white md:text-[2.2rem]">
{currentSession.goal}
</h1>
<p className="text-sm text-white/68">
{currentSession.state === 'paused' ? entryCopy.resumePaused : entryCopy.resumeRunning}
</p>
{currentSession.microStep ? (
<div className="rounded-[1.1rem] border border-white/10 bg-white/[0.04] px-4 py-3">
<p className="mb-1 text-[11px] font-medium uppercase tracking-[0.16em] text-white/44">
{entryCopy.resumeMicroStepLabel}
</p>
<p className="text-[15px] text-white/82">{currentSession.microStep}</p>
</div>
</div>
) : null}
</div>
<div className="space-y-8">
{/* Scene */}
<div className="space-y-3">
<p className="text-sm font-medium text-white/80"> </p>
<div
ref={sceneContainerRef}
{...sceneDragEvents}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<button type="button" onClick={handleResumeSession} className={primaryButtonClass}>
{entryCopy.resumeCta}
</button>
<p className="text-xs text-white/48 sm:text-right">{activeRitualMeta}</p>
</div>
<p className="text-sm text-white/56">{entryCopy.resumeNewGoalHint}</p>
</div>
) : (
<div className={goalCardClass}>
<div className="space-y-3 text-center">
<h1 className="text-[2rem] font-light leading-[1.08] tracking-[-0.04em] text-white md:text-[2.9rem]">
{entryCopy.title}
</h1>
<p className="mx-auto max-w-[32rem] text-[15px] leading-6 text-white/70 md:text-base">
{entryCopy.description}
</p>
</div>
<div className="mt-8 space-y-4">
<label className="block">
<span className="sr-only">Goal</span>
<input
ref={goalInputRef}
value={goalDraft}
onChange={(event) => setGoalDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleStartSession();
}
}}
placeholder={entryCopy.goalPlaceholder}
className={cn(
"flex gap-3 overflow-x-auto pb-2 scrollbar-none",
isSceneDragging ? "cursor-grabbing" : "cursor-grab"
inputShellClass,
'text-[1.15rem] font-light tracking-[-0.02em] placeholder:text-white/34 md:text-[1.4rem]',
)}
>
{SCENE_THEMES.map(scene => (
<button
key={scene.id}
type="button"
onClick={() => {
if (!shouldSuppressSceneClick) {
setSelectedSceneId(scene.id);
}
}}
className={cn(
'group relative h-24 min-w-[120px] rounded-xl border border-white/10 text-left transition-all overflow-hidden bg-white/5 active:scale-95',
selectedSceneId === scene.id && 'border-white/40 shadow-[0_0_20px_rgba(255,255,255,0.1)]',
isSceneDragging && 'pointer-events-none'
)}
style={getSceneStageBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
>
<div className="absolute inset-0 bg-black/40 transition-opacity group-hover:bg-black/20" />
<span className="absolute bottom-2 left-2 text-sm font-medium z-10 text-white text-shadow-sm">{scene.name}</span>
{selectedSceneId === scene.id && (
<span className="absolute top-2 right-2 z-20 flex h-5 w-5 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm border border-white/40 text-white text-[10px]">
</span>
)}
</button>
))}
</div>
</div>
autoFocus
/>
</label>
{/* Sound */}
<div className="space-y-3">
<p className="text-sm font-medium text-white/80"></p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{SOUND_PRESETS.map(sound => (
<button
key={sound.id}
type="button"
onClick={() => setSelectedSoundId(sound.id)}
className={cn(itemCardGlassClass, "py-3", selectedSoundId === sound.id && itemCardGlassSelectedClass)}
>
<span className="text-sm font-medium">{sound.label}</span>
</button>
))}
</div>
<label className="block space-y-2">
<span className="text-[12px] font-medium uppercase tracking-[0.16em] text-white/46">
{entryCopy.microStepLabel}
</span>
<input
value={microStepDraft}
onChange={(event) => setMicroStepDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleStartSession();
}
}}
placeholder={entryCopy.microStepPlaceholder}
className={cn(inputShellClass, 'text-[0.98rem] placeholder:text-white/30')}
/>
</label>
<p className="text-sm text-white/48">{entryCopy.microStepHelper}</p>
</div>
<div className="mt-6 flex flex-wrap gap-2.5">
{GOAL_SUGGESTIONS.map((suggestion) => {
const isActive = trimmedGoal === suggestion.label;
return (
<button
key={suggestion.id}
type="button"
onClick={() => handleSelectSuggestion(suggestion.label)}
className={cn(
'rounded-full border px-3.5 py-1.5 text-sm transition',
isActive
? 'border-white/32 bg-white/14 text-white'
: 'border-white/14 bg-white/[0.04] text-white/72 hover:border-white/22 hover:text-white',
)}
>
{suggestion.label}
</button>
);
})}
</div>
<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<button
type="button"
onClick={() => {
void handleStartSession();
}}
disabled={!canStart}
className={primaryButtonClass}
>
{isStartingSession ? entryCopy.startLoading : entryCopy.startNow}
</button>
<div className="space-y-1 text-left sm:text-right">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-white/44">
{entryCopy.ritualHint}
</p>
<p className="text-xs text-white/52">{entryCopy.ritualHelper}</p>
</div>
</div>
</div>
<div className="mt-10 pt-6 flex justify-end border-t border-white/10">
<button
type="button"
onClick={handleStartSession}
disabled={isStartingSession}
className={primaryGlassBtnClass}
>
{isStartingSession ? '공간으로 이동 중...' : '입장하기'}
</button>
{sessionLookupError ? (
<p className="mt-5 text-sm text-amber-100/80">{entryCopy.loadFailed}</p>
) : null}
</div>
</div>
)}
</div>
</main>
{/* Plan Sheet & Paywall */}
<FocusPlanManageSheet
isOpen={manageSheetOpen}
planItems={planItems}
selectedPlanItemId={selectedPlanItemId}
editingState={editingState}
isSaving={isSaving}
canAddMore={canAddMore}
isPro={isPro}
inputRef={inputRef}
onClose={() => {
if (!isSaving) {
setManageSheetOpen(false);
setEditingState(null);
}
}}
onAddBlock={handleAddBlock}
onDraftChange={handleManageDraftChange}
onSelect={handleSelectPlanItem}
onEdit={handleEditRow}
onDelete={(itemId) => {
void handleDeleteRow(itemId);
}}
onSave={() => {
void handleSaveEdit();
}}
onCancel={handleCancelEdit}
/>
{paywallSource ? (
<div className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center">
<button
type="button"
aria-label={copy.modal.closeAriaLabel}
onClick={() => setPaywallSource(null)}
className="absolute inset-0 bg-slate-950/48 backdrop-blur-[2px]"
className="absolute inset-0 bg-slate-950/52 backdrop-blur-[3px]"
/>
<div className="relative z-10 w-full max-w-md rounded-3xl border border-white/12 bg-[linear-gradient(165deg,rgba(15,23,42,0.94)_0%,rgba(2,6,23,0.98)_100%)] p-5 shadow-[0_24px_60px_rgba(2,6,23,0.36)]">
<p className="mb-3 text-[11px] uppercase tracking-[0.16em] text-white/42">
{focusEntryCopy.paywallLead}
</p>
<p className="mb-4 text-sm text-white/62">
{focusEntryCopy.paywallBody}
{entryCopy.paywallLead}
</p>
<p className="mb-4 text-sm text-white/62">{entryCopy.paywallBody}</p>
<PaywallSheetContent
onStartPro={() => {
setPlan('pro');

View File

@@ -4,11 +4,24 @@ import type { FormEvent } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
import {
HUD_FIELD,
HUD_OPTION_CHEVRON,
HUD_OPTION_ROW,
HUD_OPTION_ROW_PRIMARY,
HUD_TEXT_LINK,
HUD_TEXT_LINK_STRONG,
HUD_TRAY_HAIRLINE,
HUD_TRAY_LAYER,
HUD_TRAY_SHELL,
} from './overlayStyles';
interface GoalCompleteSheetProps {
open: boolean;
currentGoal: string;
preferredView?: 'choice' | 'next';
onConfirm: (nextGoal: string) => Promise<boolean> | boolean;
onFinish: () => Promise<boolean> | boolean;
onRest: () => void;
onClose: () => void;
}
@@ -16,18 +29,22 @@ interface GoalCompleteSheetProps {
export const GoalCompleteSheet = ({
open,
currentGoal,
preferredView = 'choice',
onConfirm,
onFinish,
onRest,
onClose,
}: GoalCompleteSheetProps) => {
const inputRef = useRef<HTMLInputElement | null>(null);
const [draft, setDraft] = useState('');
const [isSubmitting, setSubmitting] = useState(false);
const [submissionMode, setSubmissionMode] = useState<'next' | 'finish' | null>(null);
const [view, setView] = useState<'choice' | 'next'>('choice');
useEffect(() => {
if (!open) {
const timeoutId = window.setTimeout(() => {
setDraft('');
setView(preferredView);
}, 0);
return () => {
@@ -35,6 +52,10 @@ export const GoalCompleteSheet = ({
};
}
if (view !== 'next') {
return;
}
const rafId = window.requestAnimationFrame(() => {
inputRef.current?.focus();
});
@@ -42,7 +63,15 @@ export const GoalCompleteSheet = ({
return () => {
window.cancelAnimationFrame(rafId);
};
}, [open]);
}, [open, preferredView, view]);
useEffect(() => {
if (!open) {
return;
}
setView(preferredView);
}, [open, preferredView]);
const placeholder = useMemo(() => {
const trimmed = currentGoal.trim();
@@ -55,6 +84,9 @@ export const GoalCompleteSheet = ({
}, [currentGoal]);
const canConfirm = draft.trim().length > 0;
const isSubmitting = submissionMode !== null;
const trimmedCurrentGoal = currentGoal.trim();
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -62,7 +94,7 @@ export const GoalCompleteSheet = ({
return;
}
setSubmitting(true);
setSubmissionMode('next');
try {
const didAdvance = await onConfirm(draft.trim());
@@ -71,7 +103,25 @@ export const GoalCompleteSheet = ({
onClose();
}
} finally {
setSubmitting(false);
setSubmissionMode(null);
}
};
const handleFinish = async () => {
if (isSubmitting) {
return;
}
setSubmissionMode('finish');
try {
const didFinish = await onFinish();
if (didFinish) {
onClose();
}
} finally {
setSubmissionMode(null);
}
};
@@ -85,12 +135,9 @@ export const GoalCompleteSheet = ({
)}
aria-hidden={!open}
>
<section className="pointer-events-auto relative mt-3 w-full overflow-hidden rounded-[22px] border border-white/10 bg-[#0f1115]/28 px-5 py-4 text-white shadow-[0_12px_28px_rgba(2,6,23,0.14)] backdrop-blur-[8px] backdrop-saturate-125">
<div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[22px] bg-[linear-gradient(180deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.025)_24%,rgba(255,255,255,0.01)_100%)]"
/>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-px bg-white/16" />
<section className={HUD_TRAY_SHELL}>
<div aria-hidden className={HUD_TRAY_LAYER} />
<div aria-hidden className={HUD_TRAY_HAIRLINE} />
<header className="relative flex items-start justify-between gap-2">
<div>
@@ -109,32 +156,115 @@ export const GoalCompleteSheet = ({
</button>
</header>
<form className="relative mt-3 space-y-3" onSubmit={handleSubmit}>
<input
ref={inputRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={placeholder}
className="h-11 w-full rounded-[18px] border border-white/10 bg-black/14 px-3.5 text-[0.98rem] tracking-tight text-white placeholder:text-white/30 focus:border-white/20 focus:bg-black/20 focus:outline-none focus:ring-2 focus:ring-white/8"
/>
<footer className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
onClick={onRest}
disabled={isSubmitting}
className="inline-flex h-8 items-center justify-center rounded-full border border-white/10 bg-black/14 px-3 text-[11px] font-medium tracking-[0.14em] text-white/62 backdrop-blur-md transition-all hover:bg-black/20 hover:text-white/84 disabled:cursor-not-allowed disabled:border-white/6 disabled:bg-black/10 disabled:text-white/26"
>
{copy.space.goalComplete.restButton}
</button>
<button
type="submit"
disabled={!canConfirm || isSubmitting}
className="inline-flex h-8 items-center justify-center rounded-full border border-white/12 bg-black/18 px-3.5 text-[11px] font-semibold tracking-[0.16em] text-white/88 backdrop-blur-md transition-all hover:bg-black/24 hover:text-white disabled:cursor-not-allowed disabled:border-white/8 disabled:bg-black/10 disabled:text-white/30"
>
{isSubmitting ? copy.space.goalComplete.confirmPending : copy.space.goalComplete.confirmButton}
</button>
</footer>
</form>
{view === 'choice' ? (
<div className="relative mt-3 space-y-3">
{trimmedCurrentGoal ? (
<div className="rounded-[18px] border border-white/8 bg-black/10 px-3.5 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/36">
{copy.space.goalComplete.currentGoalLabel}
</p>
<p className="mt-1 truncate text-[14px] text-white/86">{trimmedCurrentGoal}</p>
</div>
) : null}
<footer className="mt-4 space-y-2">
<button
type="button"
onClick={handleFinish}
disabled={isSubmitting}
className={HUD_OPTION_ROW}
>
<div>
<p className="text-[13px] font-medium tracking-[0.01em] text-white/78">
{submissionMode === 'finish'
? copy.space.goalComplete.finishPending
: copy.space.goalComplete.finishButton}
</p>
<p className="mt-1 text-[12px] text-white/44">
{copy.space.goalComplete.finishDescription}
</p>
</div>
<span aria-hidden className={HUD_OPTION_CHEVRON}></span>
</button>
<button
type="button"
onClick={onRest}
disabled={isSubmitting}
className={HUD_OPTION_ROW}
>
<div>
<p className="text-[13px] font-medium tracking-[0.01em] text-white/78">
{copy.space.goalComplete.restButton}
</p>
<p className="mt-1 text-[12px] text-white/44">
{copy.space.goalComplete.restDescription}
</p>
</div>
<span aria-hidden className={HUD_OPTION_CHEVRON}></span>
</button>
<button
type="button"
onClick={() => setView('next')}
disabled={isSubmitting}
className={cn(HUD_OPTION_ROW, HUD_OPTION_ROW_PRIMARY)}
>
<div>
<p className="text-[13px] font-semibold tracking-[0.01em] text-white/90">
{copy.space.goalComplete.chooseNextButton}
</p>
<p className="mt-1 text-[12px] text-white/48">
{copy.space.goalComplete.chooseNextDescription}
</p>
</div>
<span aria-hidden className={HUD_OPTION_CHEVRON}></span>
</button>
</footer>
</div>
) : (
<form className="relative mt-3 space-y-3" onSubmit={handleSubmit}>
{trimmedCurrentGoal ? (
<div className="rounded-[18px] border border-white/8 bg-black/10 px-3.5 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/36">
{copy.space.goalComplete.currentGoalLabel}
</p>
<p className="mt-1 truncate text-[14px] text-white/86">{trimmedCurrentGoal}</p>
</div>
) : null}
<label className="block">
<span className="mb-2 block text-[11px] font-medium uppercase tracking-[0.16em] text-white/36">
{copy.space.goalComplete.nextGoalLabel}
</span>
<input
ref={inputRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={placeholder}
className={HUD_FIELD}
/>
</label>
<footer className="mt-3 flex items-center justify-end gap-2">
<button
type="button"
onClick={() => setView('choice')}
disabled={isSubmitting}
className={HUD_TEXT_LINK}
>
{copy.space.goalComplete.backButton}
</button>
<button
type="submit"
disabled={!canConfirm || isSubmitting}
className={HUD_TEXT_LINK_STRONG}
>
{submissionMode === 'next'
? copy.space.goalComplete.confirmPending
: copy.space.goalComplete.confirmButton}
</button>
</footer>
</form>
)}
</section>
</div>
);

View File

@@ -2,9 +2,18 @@
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
import {
HUD_OPTION_CHEVRON,
HUD_OPTION_ROW,
HUD_OPTION_ROW_PRIMARY,
HUD_TRAY_HAIRLINE,
HUD_TRAY_LAYER,
HUD_TRAY_SHELL,
} from './overlayStyles';
interface NextMicroStepPromptProps {
open: boolean;
goal: string;
isSubmitting: boolean;
error: string | null;
onKeepGoalOnly: () => void;
@@ -13,11 +22,14 @@ interface NextMicroStepPromptProps {
export const NextMicroStepPrompt = ({
open,
goal,
isSubmitting,
error,
onKeepGoalOnly,
onDefineNext,
}: NextMicroStepPromptProps) => {
const trimmedGoal = goal.trim();
return (
<div
className={cn(
@@ -28,12 +40,9 @@ export const NextMicroStepPrompt = ({
)}
aria-hidden={!open}
>
<section className="pointer-events-auto relative mt-3 w-full overflow-hidden rounded-[22px] border border-white/10 bg-[#0f1115]/28 px-5 py-4 text-white shadow-[0_12px_28px_rgba(2,6,23,0.14)] backdrop-blur-[8px] backdrop-saturate-125">
<div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[22px] bg-[linear-gradient(180deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.025)_24%,rgba(255,255,255,0.01)_100%)]"
/>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-px bg-white/16" />
<section className={HUD_TRAY_SHELL}>
<div aria-hidden className={HUD_TRAY_LAYER} />
<div aria-hidden className={HUD_TRAY_HAIRLINE} />
<div className="relative w-full">
<p className="text-[11px] font-medium tracking-[0.08em] text-white/42"> </p>
@@ -44,28 +53,53 @@ export const NextMicroStepPrompt = ({
{copy.space.focusHud.microStepPromptDescription}
</p>
{trimmedGoal ? (
<div className="mt-3 rounded-[16px] border border-white/8 bg-black/10 px-3.5 py-3">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/36">
{copy.space.focusHud.intentLabel}
</p>
<p className="mt-1 truncate text-[14px] text-white/84">{trimmedGoal}</p>
</div>
) : null}
{error ? (
<p className="mt-3 text-[12px] text-rose-100/86">
{error}
</p>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-4">
<div className="mt-4 space-y-2">
<button
type="button"
onClick={onKeepGoalOnly}
disabled={isSubmitting}
className="text-[12px] font-medium tracking-[0.08em] text-white/62 underline decoration-white/16 underline-offset-4 transition-all duration-200 hover:text-white/86 hover:decoration-white/28 disabled:cursor-default disabled:text-white/26 disabled:no-underline"
className={HUD_OPTION_ROW}
>
{copy.space.focusHud.microStepPromptKeep}
<div>
<p className="text-[13px] font-medium tracking-[0.01em] text-white/78">
{copy.space.focusHud.microStepPromptKeep}
</p>
<p className="mt-1 text-[12px] text-white/44">
{copy.space.focusHud.microStepPromptKeepHint}
</p>
</div>
<span aria-hidden className={HUD_OPTION_CHEVRON}></span>
</button>
<button
type="button"
onClick={onDefineNext}
disabled={isSubmitting}
className="text-[12px] font-semibold tracking-[0.08em] text-white/86 underline decoration-white/22 underline-offset-4 transition-all duration-200 hover:text-white hover:decoration-white/36 disabled:cursor-default disabled:text-white/30 disabled:no-underline"
className={cn(HUD_OPTION_ROW, HUD_OPTION_ROW_PRIMARY)}
>
{copy.space.focusHud.microStepPromptDefine}
<div>
<p className="text-[13px] font-semibold tracking-[0.01em] text-white/90">
{copy.space.focusHud.microStepPromptDefine}
</p>
<p className="mt-1 text-[12px] text-white/48">
{copy.space.focusHud.microStepPromptDefineHint}
</p>
</div>
<span aria-hidden className={HUD_OPTION_CHEVRON}></span>
</button>
</div>
</div>

View File

@@ -0,0 +1,93 @@
'use client';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
import {
HUD_OPTION_CHEVRON,
HUD_OPTION_ROW,
HUD_OPTION_ROW_PRIMARY,
HUD_PAUSE_BODY,
HUD_PAUSE_EYEBROW,
HUD_PAUSE_TITLE,
HUD_TRAY_HAIRLINE,
HUD_TRAY_LAYER,
HUD_TRAY_SHELL,
} from './overlayStyles';
interface PauseRefocusPromptProps {
open: boolean;
isBusy: boolean;
onRefocus: () => void;
onKeepCurrent: () => void;
}
export const PauseRefocusPrompt = ({
open,
isBusy,
onRefocus,
onKeepCurrent,
}: PauseRefocusPromptProps) => {
return (
<div
className={cn(
'pointer-events-none w-full overflow-hidden transition-all duration-300 ease-out motion-reduce:duration-0',
open
? 'max-h-[24rem] translate-y-0 opacity-100'
: 'pointer-events-none max-h-0 -translate-y-2 opacity-0',
)}
aria-hidden={!open}
>
<section className={HUD_TRAY_SHELL}>
<div aria-hidden className={HUD_TRAY_LAYER} />
<div aria-hidden className={HUD_TRAY_HAIRLINE} />
<div className="relative px-6 py-5 md:px-6 md:py-5">
<p className={HUD_PAUSE_EYEBROW}>
{copy.space.focusHud.pausePromptEyebrow}
</p>
<h3 className={HUD_PAUSE_TITLE}>
{copy.space.focusHud.pausePromptTitle}
</h3>
<p className={HUD_PAUSE_BODY}>
{copy.space.focusHud.pausePromptDescription}
</p>
<div className="mt-5 space-y-2.5 border-t border-white/8 pt-4">
<button
type="button"
onClick={onRefocus}
disabled={isBusy}
className={cn(HUD_OPTION_ROW, HUD_OPTION_ROW_PRIMARY)}
>
<div className="min-w-0">
<p className="text-[14px] font-semibold leading-[1.35] tracking-[-0.01em] text-white/92">
{copy.space.focusHud.pausePromptRefocus}
</p>
<p className="mt-1.5 max-w-[20rem] text-[12px] leading-[1.5] text-white/50">
{copy.space.focusHud.pausePromptRefocusHint}
</p>
</div>
<span aria-hidden className={cn(HUD_OPTION_CHEVRON, 'mt-1')}></span>
</button>
<button
type="button"
onClick={onKeepCurrent}
disabled={isBusy}
className={HUD_OPTION_ROW}
>
<div className="min-w-0">
<p className="text-[14px] font-medium leading-[1.35] tracking-[-0.01em] text-white/82">
{copy.space.focusHud.pausePromptKeep}
</p>
<p className="mt-1.5 max-w-[20rem] text-[12px] leading-[1.5] text-white/46">
{copy.space.focusHud.pausePromptKeepHint}
</p>
</div>
<span aria-hidden className={cn(HUD_OPTION_CHEVRON, 'mt-1')}></span>
</button>
</div>
</div>
</section>
</div>
);
};

View File

@@ -4,12 +4,21 @@ import type { FormEvent } from 'react';
import { useEffect, useRef } from 'react';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
import {
HUD_FIELD,
HUD_TEXT_LINK,
HUD_TEXT_LINK_STRONG,
HUD_TRAY_HAIRLINE,
HUD_TRAY_LAYER,
HUD_TRAY_SHELL,
} from './overlayStyles';
interface RefocusSheetProps {
open: boolean;
goalDraft: string;
microStepDraft: string;
autoFocusField: 'goal' | 'microStep';
submitLabel?: string;
isSaving: boolean;
error: string | null;
onGoalChange: (value: string) => void;
@@ -23,6 +32,7 @@ export const RefocusSheet = ({
goalDraft,
microStepDraft,
autoFocusField,
submitLabel,
isSaving,
error,
onGoalChange,
@@ -91,12 +101,9 @@ export const RefocusSheet = ({
)}
aria-hidden={!open}
>
<section className="pointer-events-auto relative mt-3 w-full overflow-hidden rounded-[22px] border border-white/10 bg-[#0f1115]/28 text-white shadow-[0_12px_28px_rgba(2,6,23,0.14)] backdrop-blur-[8px] backdrop-saturate-125">
<div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[22px] bg-[linear-gradient(180deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.025)_24%,rgba(255,255,255,0.01)_100%)]"
/>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-px bg-white/16" />
<section className={HUD_TRAY_SHELL}>
<div aria-hidden className={HUD_TRAY_LAYER} />
<div aria-hidden className={HUD_TRAY_HAIRLINE} />
<header className="relative px-5 pt-4">
<div className="min-w-0">
@@ -118,7 +125,7 @@ export const RefocusSheet = ({
value={goalDraft}
onChange={(event) => onGoalChange(event.target.value)}
placeholder={copy.space.sessionGoal.placeholder}
className="h-11 w-full rounded-[18px] border border-white/10 bg-black/14 px-3.5 text-[1rem] tracking-tight text-white placeholder:text-white/28 focus:border-white/20 focus:bg-black/20 focus:outline-none focus:ring-2 focus:ring-white/8"
className={HUD_FIELD}
/>
</label>
@@ -131,7 +138,7 @@ export const RefocusSheet = ({
value={microStepDraft}
onChange={(event) => onMicroStepChange(event.target.value)}
placeholder={copy.space.sessionGoal.hint}
className="h-11 w-full rounded-[18px] border border-white/10 bg-black/12 px-3.5 text-[0.98rem] tracking-tight text-white placeholder:text-white/26 focus:border-white/20 focus:bg-black/18 focus:outline-none focus:ring-2 focus:ring-white/8"
className={HUD_FIELD}
/>
</label>
@@ -146,16 +153,16 @@ export const RefocusSheet = ({
type="button"
onClick={onClose}
disabled={isSaving}
className="inline-flex h-8 items-center justify-center rounded-full border border-white/10 bg-black/14 px-3 text-[11px] font-medium tracking-[0.14em] text-white/62 backdrop-blur-md transition-all hover:bg-black/20 hover:text-white/84 disabled:cursor-default disabled:border-white/6 disabled:bg-black/10 disabled:text-white/26"
className={HUD_TEXT_LINK}
>
{copy.common.cancel}
</button>
<button
type="submit"
disabled={isSaving || goalDraft.trim().length === 0}
className="inline-flex h-8 items-center justify-center rounded-full border border-white/12 bg-black/18 px-3 text-[11px] font-semibold tracking-[0.16em] text-white/84 backdrop-blur-md transition-all hover:bg-black/24 hover:text-white disabled:cursor-not-allowed disabled:border-white/8 disabled:bg-black/10 disabled:text-white/30"
className={HUD_TEXT_LINK_STRONG}
>
{isSaving ? copy.space.focusHud.refocusApplying : copy.space.focusHud.refocusApply}
{isSaving ? copy.space.focusHud.refocusApplying : submitLabel ?? copy.space.focusHud.refocusApply}
</button>
</footer>
</form>

View File

@@ -0,0 +1,157 @@
'use client';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
import {
HUD_OPTION_CHEVRON,
HUD_OPTION_ROW,
HUD_OPTION_ROW_PRIMARY,
HUD_TRAY_HAIRLINE,
HUD_TRAY_LAYER,
HUD_TRAY_SHELL,
} from './overlayStyles';
interface ReturnPromptProps {
open: boolean;
mode: 'focus' | 'break';
isBusy: boolean;
onContinue: () => void;
onRefocus: () => void;
onRest?: () => void;
onNextGoal?: () => void;
}
export const ReturnPrompt = ({
open,
mode,
isBusy,
onContinue,
onRefocus,
onRest,
onNextGoal,
}: ReturnPromptProps) => {
const isBreakReturn = mode === 'break';
return (
<div
className={cn(
'pointer-events-none w-full overflow-hidden transition-all duration-300 ease-out motion-reduce:duration-0',
open
? 'max-h-[22rem] translate-y-0 opacity-100'
: 'pointer-events-none max-h-0 -translate-y-2 opacity-0',
)}
aria-hidden={!open}
>
<section className={HUD_TRAY_SHELL}>
<div aria-hidden className={HUD_TRAY_LAYER} />
<div aria-hidden className={HUD_TRAY_HAIRLINE} />
<div className="relative">
<p className="text-[11px] font-medium tracking-[0.08em] text-white/42">
{copy.space.focusHud.returnPromptEyebrow}
</p>
<h3 className="mt-1 text-[1rem] font-medium tracking-tight text-white/94">
{isBreakReturn
? copy.space.focusHud.returnPromptBreakTitle
: copy.space.focusHud.returnPromptFocusTitle}
</h3>
<p className="mt-1 text-[13px] text-white/58">
{isBreakReturn
? copy.space.focusHud.returnPromptBreakDescription
: copy.space.focusHud.returnPromptFocusDescription}
</p>
<div className="mt-4 space-y-2">
{isBreakReturn ? (
<>
<button
type="button"
onClick={onRest}
disabled={isBusy}
className={cn(HUD_OPTION_ROW, HUD_OPTION_ROW_PRIMARY)}
>
<div>
<p className="text-[13px] font-semibold tracking-[0.01em] text-white/90">
{copy.space.focusHud.returnPromptRest}
</p>
<p className="mt-1 text-[12px] text-white/48">
{copy.space.focusHud.returnPromptRestHint}
</p>
</div>
<span aria-hidden className={HUD_OPTION_CHEVRON}></span>
</button>
<button
type="button"
onClick={onNextGoal}
disabled={isBusy}
className={HUD_OPTION_ROW}
>
<div>
<p className="text-[13px] font-medium tracking-[0.01em] text-white/78">
{copy.space.focusHud.returnPromptNext}
</p>
<p className="mt-1 text-[12px] text-white/44">
{copy.space.focusHud.returnPromptNextHint}
</p>
</div>
<span aria-hidden className={HUD_OPTION_CHEVRON}></span>
</button>
<button
type="button"
onClick={onRefocus}
disabled={isBusy}
className={HUD_OPTION_ROW}
>
<div>
<p className="text-[13px] font-medium tracking-[0.01em] text-white/78">
{copy.space.focusHud.returnPromptRefocus}
</p>
<p className="mt-1 text-[12px] text-white/44">
{copy.space.focusHud.returnPromptRefocusHint}
</p>
</div>
<span aria-hidden className={HUD_OPTION_CHEVRON}></span>
</button>
</>
) : (
<>
<button
type="button"
onClick={onContinue}
disabled={isBusy}
className={cn(HUD_OPTION_ROW, HUD_OPTION_ROW_PRIMARY)}
>
<div>
<p className="text-[13px] font-semibold tracking-[0.01em] text-white/90">
{copy.space.focusHud.returnPromptContinue}
</p>
<p className="mt-1 text-[12px] text-white/48">
{copy.space.focusHud.returnPromptContinueHint}
</p>
</div>
<span aria-hidden className={HUD_OPTION_CHEVRON}></span>
</button>
<button
type="button"
onClick={onRefocus}
disabled={isBusy}
className={HUD_OPTION_ROW}
>
<div>
<p className="text-[13px] font-medium tracking-[0.01em] text-white/78">
{copy.space.focusHud.returnPromptRefocus}
</p>
<p className="mt-1 text-[12px] text-white/44">
{copy.space.focusHud.returnPromptRefocusHint}
</p>
</div>
<span aria-hidden className={HUD_OPTION_CHEVRON}></span>
</button>
</>
)}
</div>
</div>
</section>
</div>
);
};

View File

@@ -5,7 +5,9 @@ import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
import { GoalCompleteSheet } from './GoalCompleteSheet';
import { IntentCapsule } from './IntentCapsule';
import { NextMicroStepPrompt } from './NextMicroStepPrompt';
import { PauseRefocusPrompt } from './PauseRefocusPrompt';
import { RefocusSheet } from './RefocusSheet';
import { ReturnPrompt } from './ReturnPrompt';
interface SpaceFocusHudWidgetProps {
goal: string;
@@ -18,11 +20,14 @@ interface SpaceFocusHudWidgetProps {
canStartSession?: boolean;
canPauseSession?: boolean;
canRestartSession?: boolean;
returnPromptMode?: 'focus' | 'break' | null;
onStartRequested?: () => void;
onPauseRequested?: () => void;
onRestartRequested?: () => void;
onDismissReturnPrompt?: () => void;
onIntentUpdate: (payload: { goal?: string; microStep?: string | null }) => boolean | Promise<boolean>;
onGoalUpdate: (nextGoal: string) => boolean | Promise<boolean>;
onGoalFinish: () => boolean | Promise<boolean>;
onStatusMessage: (payload: HudStatusLinePayload) => void;
}
@@ -37,16 +42,19 @@ export const SpaceFocusHudWidget = ({
canStartSession = false,
canPauseSession = false,
canRestartSession = false,
returnPromptMode = null,
onStartRequested,
onPauseRequested,
onRestartRequested,
onDismissReturnPrompt,
onIntentUpdate,
onGoalUpdate,
onGoalFinish,
onStatusMessage,
}: SpaceFocusHudWidgetProps) => {
const [sheetOpen, setSheetOpen] = useState(false);
const [isRefocusOpen, setRefocusOpen] = useState(false);
const [isMicroStepPromptOpen, setMicroStepPromptOpen] = useState(false);
const [overlay, setOverlay] = useState<'none' | 'paused' | 'return' | 'refocus' | 'next-beat' | 'complete'>('none');
const [refocusOrigin, setRefocusOrigin] = useState<'manual' | 'pause' | 'next-beat' | 'return'>('manual');
const [completePreferredView, setCompletePreferredView] = useState<'choice' | 'next'>('choice');
const [draftGoal, setDraftGoal] = useState('');
const [draftMicroStep, setDraftMicroStep] = useState('');
const [autoFocusField, setAutoFocusField] = useState<'goal' | 'microStep'>('goal');
@@ -58,7 +66,12 @@ export const SpaceFocusHudWidget = ({
const restReminderTimerRef = useRef<number | null>(null);
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : copy.space.focusHud.goalFallback;
const normalizedMicroStep = microStep?.trim() ? microStep.trim() : null;
const isIntentOverlayOpen = isRefocusOpen || isMicroStepPromptOpen || sheetOpen;
const isPausedPromptOpen = overlay === 'paused';
const isReturnPromptOpen = overlay === 'return';
const isRefocusOpen = overlay === 'refocus';
const isMicroStepPromptOpen = overlay === 'next-beat';
const isCompleteOpen = overlay === 'complete';
const isIntentOverlayOpen = overlay !== 'none';
useEffect(() => {
return () => {
@@ -69,6 +82,32 @@ export const SpaceFocusHudWidget = ({
};
}, []);
useEffect(() => {
if (!hasActiveSession) {
setOverlay('none');
setIntentError(null);
setSavingIntent(false);
setRefocusOrigin('manual');
setCompletePreferredView('choice');
}
}, [hasActiveSession]);
useEffect(() => {
if (!returnPromptMode) {
if (overlay === 'return') {
setOverlay('none');
}
return;
}
if (overlay === 'complete') {
return;
}
setIntentError(null);
setOverlay('return');
}, [overlay, returnPromptMode]);
useEffect(() => {
if (!visibleRef.current && playbackState === 'running') {
onStatusMessage({
@@ -89,13 +128,16 @@ export const SpaceFocusHudWidget = ({
resumePlaybackStateRef.current = playbackState;
}, [normalizedGoal, onStatusMessage, playbackState]);
const openRefocus = useCallback((field: 'goal' | 'microStep' = 'goal') => {
const openRefocus = useCallback((
field: 'goal' | 'microStep' = 'goal',
origin: 'manual' | 'pause' | 'next-beat' | 'return' = 'manual',
) => {
setDraftGoal(goal.trim());
setDraftMicroStep(normalizedMicroStep ?? '');
setAutoFocusField(field);
setIntentError(null);
setMicroStepPromptOpen(false);
setRefocusOpen(true);
setRefocusOrigin(origin);
setOverlay('refocus');
}, [goal, normalizedMicroStep]);
useEffect(() => {
@@ -103,31 +145,42 @@ export const SpaceFocusHudWidget = ({
pausePlaybackStateRef.current === 'running' &&
playbackState === 'paused' &&
hasActiveSession &&
!isRefocusOpen &&
!sheetOpen
overlay === 'none'
) {
openRefocus('microStep');
setIntentError(null);
setOverlay('paused');
onStatusMessage({
message: copy.space.focusHud.refocusOpenOnPause,
});
}
pausePlaybackStateRef.current = playbackState;
}, [hasActiveSession, isRefocusOpen, onStatusMessage, openRefocus, playbackState, sheetOpen]);
}, [hasActiveSession, onStatusMessage, overlay, playbackState]);
useEffect(() => {
if (normalizedMicroStep) {
return;
if (playbackState === 'running' && overlay === 'paused') {
setOverlay('none');
}
}, [overlay, playbackState]);
setMicroStepPromptOpen(false);
}, [normalizedMicroStep]);
useEffect(() => {
if (!normalizedMicroStep && overlay === 'next-beat') {
setOverlay('none');
}
}, [normalizedMicroStep, overlay]);
const handleOpenCompleteSheet = () => {
const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => {
setIntentError(null);
setRefocusOpen(false);
setMicroStepPromptOpen(false);
setSheetOpen(true);
setCompletePreferredView(preferredView);
setOverlay('complete');
};
const handleDismissReturnPrompt = () => {
onDismissReturnPrompt?.();
if (overlay === 'return') {
setOverlay('none');
}
};
const handleRefocusSubmit = async () => {
@@ -151,10 +204,18 @@ export const SpaceFocusHudWidget = ({
return;
}
setRefocusOpen(false);
setOverlay('none');
onStatusMessage({
message: copy.space.focusHud.refocusSaved,
});
if (refocusOrigin === 'return') {
onDismissReturnPrompt?.();
}
if (refocusOrigin === 'pause' && playbackState === 'paused') {
onStartRequested?.();
}
} finally {
setSavingIntent(false);
}
@@ -178,7 +239,7 @@ export const SpaceFocusHudWidget = ({
return;
}
setMicroStepPromptOpen(false);
setOverlay('none');
onStatusMessage({
message: copy.space.focusHud.microStepCleared,
});
@@ -192,37 +253,70 @@ export const SpaceFocusHudWidget = ({
setDraftMicroStep('');
setAutoFocusField('microStep');
setIntentError(null);
setMicroStepPromptOpen(false);
setRefocusOpen(true);
setRefocusOrigin('next-beat');
setOverlay('refocus');
};
return (
<>
<div className="pointer-events-none fixed left-6 top-6 z-20 w-[min(26rem,calc(100vw-3rem))] md:left-10 md:top-9">
<div className="pointer-events-none fixed left-6 top-6 z-20 w-[min(29rem,calc(100vw-3rem))] md:left-10 md:top-9">
<IntentCapsule
goal={normalizedGoal}
microStep={microStep}
canRefocus={Boolean(hasActiveSession)}
canComplete={hasActiveSession && sessionPhase === 'focus'}
showActions={!isIntentOverlayOpen}
onOpenRefocus={() => openRefocus()}
onOpenRefocus={() => openRefocus('goal', 'manual')}
onMicroStepDone={() => {
if (!normalizedMicroStep) {
openRefocus('microStep');
openRefocus('microStep', 'next-beat');
return;
}
setIntentError(null);
setRefocusOpen(false);
setMicroStepPromptOpen(true);
setOverlay('next-beat');
}}
onGoalCompleteRequest={handleOpenCompleteSheet}
/>
<ReturnPrompt
open={isReturnPromptOpen && Boolean(returnPromptMode)}
mode={returnPromptMode === 'break' ? 'break' : 'focus'}
isBusy={isSavingIntent}
onContinue={() => {
handleDismissReturnPrompt();
}}
onRefocus={() => {
handleDismissReturnPrompt();
openRefocus('microStep', 'return');
}}
onRest={() => {
handleDismissReturnPrompt();
onStatusMessage({ message: copy.space.focusHud.restReminder });
}}
onNextGoal={() => {
handleDismissReturnPrompt();
handleOpenCompleteSheet('next');
}}
/>
<PauseRefocusPrompt
open={isPausedPromptOpen}
isBusy={isSavingIntent}
onRefocus={() => openRefocus('microStep', 'pause')}
onKeepCurrent={() => {
setOverlay('none');
onStartRequested?.();
}}
/>
<RefocusSheet
open={isRefocusOpen}
goalDraft={draftGoal}
microStepDraft={draftMicroStep}
autoFocusField={autoFocusField}
submitLabel={
refocusOrigin === 'pause' && playbackState === 'paused'
? copy.space.focusHud.refocusApplyAndResume
: copy.space.focusHud.refocusApply
}
isSaving={isSavingIntent}
error={intentError}
onGoalChange={setDraftGoal}
@@ -233,7 +327,7 @@ export const SpaceFocusHudWidget = ({
}
setIntentError(null);
setRefocusOpen(false);
setOverlay('none');
}}
onSubmit={() => {
void handleRefocusSubmit();
@@ -241,6 +335,7 @@ export const SpaceFocusHudWidget = ({
/>
<NextMicroStepPrompt
open={isMicroStepPromptOpen}
goal={normalizedGoal}
isSubmitting={isSavingIntent}
error={intentError}
onKeepGoalOnly={() => {
@@ -249,11 +344,13 @@ export const SpaceFocusHudWidget = ({
onDefineNext={handleDefineNextMicroStep}
/>
<GoalCompleteSheet
open={sheetOpen}
open={isCompleteOpen}
currentGoal={goal}
onClose={() => setSheetOpen(false)}
preferredView={completePreferredView}
onClose={() => setOverlay('none')}
onFinish={() => Promise.resolve(onGoalFinish())}
onRest={() => {
setSheetOpen(false);
setOverlay('none');
if (restReminderTimerRef.current) {
window.clearTimeout(restReminderTimerRef.current);

View File

@@ -0,0 +1,34 @@
export const HUD_TRAY_SHELL =
'pointer-events-auto relative mt-3 w-full overflow-hidden rounded-[22px] border border-white/10 bg-[#101318]/30 text-white shadow-[0_12px_28px_rgba(2,6,23,0.14)] backdrop-blur-[8px] backdrop-saturate-125';
export const HUD_TRAY_LAYER =
'pointer-events-none absolute inset-0 rounded-[22px] bg-[linear-gradient(180deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.025)_24%,rgba(255,255,255,0.01)_100%)]';
export const HUD_TRAY_HAIRLINE = 'pointer-events-none absolute inset-x-0 top-0 h-px bg-white/16';
export const HUD_FIELD =
'h-11 w-full rounded-[18px] border border-white/10 bg-black/14 px-3.5 text-[0.98rem] tracking-tight text-white placeholder:text-white/30 focus:border-white/20 focus:bg-black/20 focus:outline-none focus:ring-2 focus:ring-white/8';
export const HUD_OPTION_ROW =
'group flex w-full items-start justify-between gap-4 rounded-[20px] border border-white/8 bg-black/10 px-4 py-3.5 text-left transition-all duration-200 hover:border-white/14 hover:bg-black/14 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/10 disabled:cursor-not-allowed disabled:border-white/6 disabled:bg-black/8 disabled:text-white/30';
export const HUD_OPTION_ROW_PRIMARY =
'border-white/12 bg-black/14 hover:border-white/18 hover:bg-black/18';
export const HUD_OPTION_CHEVRON =
'mt-0.5 shrink-0 text-[13px] text-white/28 transition-colors duration-200 group-hover:text-white/52';
export const HUD_PAUSE_EYEBROW =
'text-[11px] font-medium tracking-[0.12em] text-white/42';
export const HUD_PAUSE_TITLE =
'mt-2 max-w-[24rem] text-[1.18rem] font-medium leading-[1.34] tracking-[-0.02em] text-white/95 md:text-[1.28rem]';
export const HUD_PAUSE_BODY =
'mt-2 max-w-[23rem] text-[13px] leading-[1.6] text-white/58 md:text-[13.5px]';
export const HUD_TEXT_LINK =
'text-[12px] font-medium tracking-[0.08em] text-white/62 underline decoration-white/16 underline-offset-4 transition-all duration-200 hover:text-white/84 hover:decoration-white/28 disabled:cursor-default disabled:text-white/26 disabled:no-underline';
export const HUD_TEXT_LINK_STRONG =
'text-[12px] font-semibold tracking-[0.08em] text-white/86 underline decoration-white/22 underline-offset-4 transition-all duration-200 hover:text-white hover:decoration-white/36 disabled:cursor-default disabled:text-white/30 disabled:no-underline';

View File

@@ -0,0 +1,184 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { FocusSession } from '@/features/focus-session';
const AWAY_HIDDEN_THRESHOLD_MS = 20_000;
const AWAY_SLEEP_GAP_THRESHOLD_MS = 90_000;
const HEARTBEAT_INTERVAL_MS = 15_000;
export type ReturnPromptMode = 'focus' | 'break';
interface UseAwayReturnRecoveryParams {
currentSession: FocusSession | null;
isBootstrapping: boolean;
syncCurrentSession: () => Promise<FocusSession | null>;
}
interface UseAwayReturnRecoveryResult {
returnPromptMode: ReturnPromptMode | null;
dismissReturnPrompt: () => void;
}
export const useAwayReturnRecovery = ({
currentSession,
isBootstrapping,
syncCurrentSession,
}: UseAwayReturnRecoveryParams): UseAwayReturnRecoveryResult => {
const [returnPromptMode, setReturnPromptMode] = useState<ReturnPromptMode | null>(null);
const hiddenAtRef = useRef<number | null>(null);
const awayCandidateRef = useRef(false);
const heartbeatAtRef = useRef(Date.now());
const isHandlingReturnRef = useRef(false);
const isRunningFocusSession =
currentSession?.state === 'running' && currentSession.phase === 'focus';
const clearAwayCandidate = useCallback(() => {
hiddenAtRef.current = null;
awayCandidateRef.current = false;
}, []);
const dismissReturnPrompt = useCallback(() => {
setReturnPromptMode(null);
clearAwayCandidate();
}, [clearAwayCandidate]);
useEffect(() => {
heartbeatAtRef.current = Date.now();
}, [currentSession?.id]);
useEffect(() => {
if (!isRunningFocusSession) {
clearAwayCandidate();
return;
}
const intervalId = window.setInterval(() => {
if (document.visibilityState === 'visible') {
heartbeatAtRef.current = Date.now();
}
}, HEARTBEAT_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [clearAwayCandidate, isRunningFocusSession]);
useEffect(() => {
if (currentSession?.state !== 'running') {
if (returnPromptMode === 'focus') {
setReturnPromptMode(null);
}
return;
}
if (currentSession.phase !== 'break' && returnPromptMode === 'break') {
setReturnPromptMode(null);
}
}, [currentSession?.phase, currentSession?.state, returnPromptMode]);
useEffect(() => {
if (isBootstrapping) {
return;
}
const maybeHandleReturn = async () => {
if (isHandlingReturnRef.current) {
return;
}
const hiddenDuration =
hiddenAtRef.current == null ? 0 : Date.now() - hiddenAtRef.current;
const sleepGap = Date.now() - heartbeatAtRef.current;
if (!awayCandidateRef.current) {
if (
isRunningFocusSession &&
document.visibilityState === 'visible' &&
sleepGap >= AWAY_SLEEP_GAP_THRESHOLD_MS
) {
awayCandidateRef.current = true;
} else {
return;
}
}
if (hiddenAtRef.current != null && hiddenDuration < AWAY_HIDDEN_THRESHOLD_MS) {
clearAwayCandidate();
heartbeatAtRef.current = Date.now();
return;
}
isHandlingReturnRef.current = true;
try {
const syncedSession = await syncCurrentSession();
const resolvedSession = syncedSession ?? currentSession;
clearAwayCandidate();
heartbeatAtRef.current = Date.now();
if (!resolvedSession || resolvedSession.state !== 'running') {
setReturnPromptMode(null);
return;
}
if (resolvedSession.phase === 'focus') {
setReturnPromptMode('focus');
return;
}
if (resolvedSession.phase === 'break') {
setReturnPromptMode('break');
return;
}
setReturnPromptMode(null);
} finally {
isHandlingReturnRef.current = false;
}
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
if (isRunningFocusSession) {
hiddenAtRef.current = Date.now();
awayCandidateRef.current = true;
}
return;
}
void maybeHandleReturn();
};
const handlePageHide = () => {
if (isRunningFocusSession) {
hiddenAtRef.current = Date.now();
awayCandidateRef.current = true;
}
};
const handleWindowFocus = () => {
void maybeHandleReturn();
};
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('pagehide', handlePageHide);
window.addEventListener('focus', handleWindowFocus);
window.addEventListener('pageshow', handleWindowFocus);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('pagehide', handlePageHide);
window.removeEventListener('focus', handleWindowFocus);
window.removeEventListener('pageshow', handleWindowFocus);
};
}, [clearAwayCandidate, currentSession, isBootstrapping, isRunningFocusSession, syncCurrentSession]);
return {
returnPromptMode,
dismissReturnPrompt,
};
};

View File

@@ -40,6 +40,12 @@ interface UseSpaceWorkspaceSessionControlsParams {
goal?: string;
microStep?: string | null;
}) => Promise<FocusSession | null>;
completeSession: (payload: {
completionType: 'goal-complete' | 'timer-complete';
completedGoal?: string;
focusScore?: number;
distractionCount?: number;
}) => Promise<FocusSession | null>;
advanceGoal: (input: {
completedGoal: string;
nextGoal: string;
@@ -78,6 +84,7 @@ export const useSpaceWorkspaceSessionControls = ({
resumeSession,
restartCurrentPhase,
updateCurrentIntent,
completeSession,
advanceGoal,
abandonSession,
setGoalInput,
@@ -294,6 +301,47 @@ export const useSpaceWorkspaceSessionControls = ({
unlockPlayback,
]);
const handleGoalComplete = useCallback(async () => {
const trimmedCurrentGoal = goalInput.trim();
if (!currentSession) {
return false;
}
const completedSession = await completeSession({
completionType: 'goal-complete',
completedGoal: trimmedCurrentGoal || undefined,
});
if (!completedSession) {
pushStatusLine({
message: copy.space.workspace.goalCompleteSyncFailed,
});
return false;
}
setGoalInput('');
setLinkedFocusPlanItemId(null);
setSelectedGoalId(null);
setShowResumePrompt(false);
setPendingSessionEntryPoint('space-setup');
setPreviewPlaybackState('paused');
setWorkspaceMode('setup');
return true;
}, [
completeSession,
currentSession,
goalInput,
pushStatusLine,
setGoalInput,
setLinkedFocusPlanItemId,
setPendingSessionEntryPoint,
setPreviewPlaybackState,
setSelectedGoalId,
setShowResumePrompt,
setWorkspaceMode,
]);
const handleIntentUpdate = useCallback(async (input: {
goal?: string;
microStep?: string | null;
@@ -407,6 +455,7 @@ export const useSpaceWorkspaceSessionControls = ({
handlePauseRequested,
handleRestartRequested,
handleIntentUpdate,
handleGoalComplete,
handleGoalAdvance,
};
};

View File

@@ -20,6 +20,7 @@ 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 { useAwayReturnRecovery } from "../model/useAwayReturnRecovery";
import { useSpaceWorkspaceSelection } from "../model/useSpaceWorkspaceSelection";
import { useSpaceWorkspaceSessionControls } from "../model/useSpaceWorkspaceSessionControls";
import {
@@ -117,8 +118,10 @@ export const SpaceWorkspaceWidget = () => {
restartCurrentPhase,
updateCurrentIntent,
updateCurrentSelection,
completeSession,
advanceGoal,
abandonSession,
syncCurrentSession,
} = useFocusSessionEngine();
const isFocusMode = workspaceMode === "focus";
@@ -191,6 +194,7 @@ export const SpaceWorkspaceWidget = () => {
resumeSession,
restartCurrentPhase,
updateCurrentIntent,
completeSession,
advanceGoal,
abandonSession,
setGoalInput: selection.setGoalInput,
@@ -199,6 +203,12 @@ export const SpaceWorkspaceWidget = () => {
setShowResumePrompt: selection.setShowResumePrompt,
});
const awayReturnRecovery = useAwayReturnRecovery({
currentSession,
isBootstrapping,
syncCurrentSession,
});
useEffect(() => {
if (!isBootstrapping && !currentSession && !hasQueryOverrides) {
router.replace("/app");
@@ -296,6 +306,7 @@ export const SpaceWorkspaceWidget = () => {
canStartSession={controls.canStartSession}
canPauseSession={controls.canPauseSession}
canRestartSession={controls.canRestartSession}
returnPromptMode={awayReturnRecovery.returnPromptMode}
onStartRequested={() => {
void controls.handleStartRequested();
}}
@@ -305,8 +316,10 @@ export const SpaceWorkspaceWidget = () => {
onRestartRequested={() => {
void controls.handleRestartRequested();
}}
onDismissReturnPrompt={awayReturnRecovery.dismissReturnPrompt}
onStatusMessage={pushStatusLine}
onIntentUpdate={controls.handleIntentUpdate}
onGoalFinish={controls.handleGoalComplete}
onGoalUpdate={controls.handleGoalAdvance}
/>
) : null}