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

@@ -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';