refactor(space): focus hud를 inline 구조로 단순화

This commit is contained in:
2026-03-16 15:17:01 +09:00
parent fb2729193f
commit b91fdbcb67
8 changed files with 254 additions and 913 deletions

View File

@@ -0,0 +1,145 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { cn } from '@/shared/lib/cn';
interface InlineMicrostepProps {
microStep: string | null;
isBusy: boolean;
onUpdate: (nextStep: string | null) => Promise<boolean>;
}
export const InlineMicrostep = ({ microStep, isBusy, onUpdate }: InlineMicrostepProps) => {
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState('');
const [isCompleting, setIsCompleting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const normalizedStep = microStep?.trim() || null;
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
}
}, [isEditing]);
const startEditing = () => {
if (isBusy || isCompleting) return;
setDraft(normalizedStep ?? '');
setIsEditing(true);
};
const cancelEditing = () => {
setIsEditing(false);
setDraft('');
};
const submitDraft = async () => {
const trimmed = draft.trim();
if (trimmed === normalizedStep) {
cancelEditing();
return;
}
const success = await onUpdate(trimmed || null);
if (success) {
cancelEditing();
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
void submitDraft();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEditing();
}
};
const handleComplete = async () => {
if (isBusy || isCompleting || !normalizedStep) return;
setIsCompleting(true);
// Visual delay for the strikethrough animation before actually clearing it
setTimeout(async () => {
await onUpdate(null);
setIsCompleting(false);
}, 400);
};
if (isEditing) {
return (
<div className="flex w-full max-w-md items-center gap-3 rounded-full border border-white/20 bg-black/40 pl-5 pr-2 py-1.5 shadow-[0_0_30px_rgba(255,255,255,0.1)] backdrop-blur-2xl transition-all duration-300 animate-fade-in-up">
<input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => void submitDraft()}
disabled={isBusy}
placeholder="Enter next small step..."
className="flex-1 bg-transparent text-[15px] font-medium text-white outline-none placeholder:text-white/30 disabled:opacity-50"
/>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault(); // Prevent blur
void submitDraft();
}}
disabled={isBusy || !draft.trim()}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white text-black transition-transform hover:scale-105 active:scale-95 disabled:opacity-30 disabled:hover:scale-100"
>
<svg viewBox="0 0 16 16" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M3 8.5 6.5 12 13 4" />
</svg>
</button>
</div>
);
}
if (normalizedStep) {
return (
<div
className={cn(
"group/micro flex w-full max-w-md items-center gap-4 rounded-full border border-white/5 bg-white/5 pl-4 pr-6 py-2.5 backdrop-blur-xl transition-all duration-500",
isCompleting ? "opacity-0 scale-95" : "hover:border-white/15 hover:bg-white/10 opacity-100 scale-100"
)}
>
<button
type="button"
onClick={() => void handleComplete()}
disabled={isBusy || isCompleting}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-white/30 text-white/0 transition-all duration-300 hover:bg-white hover:text-black focus:outline-none focus:ring-2 active:scale-90"
>
<svg viewBox="0 0 16 16" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 8.4 6.7 11 12 5.7" />
</svg>
</button>
<button
onClick={startEditing}
disabled={isBusy || isCompleting}
className="flex-1 text-left"
>
<p className={cn(
"text-[16px] text-white/80 transition-all duration-300 group-hover/micro:text-white truncate",
isCompleting && "line-through text-white/30 decoration-white/30"
)}>
{normalizedStep}
</p>
</button>
</div>
);
}
return (
<button
type="button"
onClick={startEditing}
disabled={isBusy}
className="inline-flex items-center gap-2 rounded-full border border-dashed border-white/20 bg-transparent px-6 py-2.5 text-[14px] font-medium text-white/40 transition-all hover:border-white/40 hover:text-white/80 active:scale-95 disabled:opacity-50"
>
<span className="text-lg leading-none mb-0.5">+</span> Add microstep
</button>
);
};

View File

@@ -1,228 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
interface IntentCapsuleProps {
goal: string;
microStep?: string | null;
canRefocus: boolean;
canComplete?: boolean;
showActions?: boolean;
onOpenRefocus: () => void;
onMicroStepDone: () => void;
onGoalCompleteRequest?: () => void;
}
export const IntentCapsule = ({
goal,
microStep,
canRefocus,
canComplete = false,
showActions = true,
onOpenRefocus,
onMicroStepDone,
onGoalCompleteRequest,
}: IntentCapsuleProps) => {
const [isPinnedExpanded, setPinnedExpanded] = useState(false);
const [isHovered, setHovered] = useState(false);
const [isFocusWithin, setFocusWithin] = useState(false);
const sectionRef = useRef<HTMLElement | null>(null);
const normalizedMicroStep = microStep?.trim() ? microStep.trim() : null;
const isExpanded = showActions && (isPinnedExpanded || isHovered || isFocusWithin);
const canInteract = showActions && canRefocus;
const microGlyphClass =
'inline-flex h-5 w-5 items-center justify-center rounded-full border border-white/18 text-white/62 transition-all duration-200 hover:border-white/32 hover:text-white focus-visible:border-white/32 focus-visible:text-white disabled:cursor-default disabled:opacity-30';
useEffect(() => {
if (!isExpanded || !showActions) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (sectionRef.current?.contains(target)) {
return;
}
setPinnedExpanded(false);
setHovered(false);
setFocusWithin(false);
};
document.addEventListener('pointerdown', handlePointerDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
};
}, [isExpanded, showActions]);
const handleExpand = () => {
if (!showActions) {
return;
}
setPinnedExpanded(true);
};
return (
<div className="pointer-events-none flex w-full">
<section
ref={sectionRef}
className={cn(
'pointer-events-auto relative overflow-hidden border text-white transition-[width,padding,background-color,border-color,box-shadow] duration-200 ease-out',
isExpanded
? 'w-[22rem] max-w-full rounded-[24px] border-white/10 bg-[#0f1115]/24 px-5 py-4 shadow-[0_12px_28px_rgba(2,6,23,0.14)] backdrop-blur-[8px] backdrop-saturate-125'
: 'w-[18.75rem] max-w-full rounded-[20px] border-white/8 bg-[#0f1115]/16 px-4 py-3 shadow-[0_8px_18px_rgba(2,6,23,0.10)] backdrop-blur-[7px] backdrop-saturate-120',
)}
onMouseEnter={() => {
if (showActions) {
setHovered(true);
}
}}
onMouseLeave={() => setHovered(false)}
onFocusCapture={() => {
if (showActions) {
setFocusWithin(true);
}
}}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setFocusWithin(false);
}
}}
>
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-0',
isExpanded
? 'rounded-[24px] 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%)]'
: 'rounded-[20px] bg-[linear-gradient(180deg,rgba(255,255,255,0.06)_0%,rgba(255,255,255,0.018)_28%,rgba(255,255,255,0.008)_100%)]',
)}
/>
<div
aria-hidden
className={cn(
'pointer-events-none absolute inset-x-0 top-0 h-px',
isExpanded ? 'bg-white/16' : 'bg-white/12',
)}
/>
<div className="relative">
{isExpanded ? (
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="min-w-0 truncate text-[18px] font-medium tracking-tight text-white/96 md:text-[20px]">
{goal}
</p>
</div>
{canInteract ? (
<button
type="button"
onClick={onOpenRefocus}
aria-label={copy.space.focusHud.refocusButton}
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-white/10 bg-black/10 px-2 py-1 text-[11px] font-medium tracking-[0.06em] text-white/54 transition-colors duration-200 hover:text-white/72 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/14"
>
<svg
aria-hidden
viewBox="0 0 16 16"
className="h-3 w-3"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
>
<path d="m10.9 2.6 2.5 2.5" />
<path d="M3.4 12.6 2.6 15l2.4-.8 7.7-7.7-1.6-1.6-7.7 7.7Z" />
</svg>
<span>{copy.space.focusHud.intentEditLabel}</span>
</button>
) : null}
</div>
) : (
<button
type="button"
onClick={handleExpand}
disabled={!showActions}
aria-label={copy.space.focusHud.intentExpandAriaLabel}
className="flex min-w-0 w-full items-center text-left transition-opacity hover:opacity-92 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/14 disabled:cursor-default disabled:hover:opacity-100"
>
<p
className={cn(
'min-w-0 flex-1 truncate font-medium tracking-tight text-white/96 transition-[font-size,line-height] duration-200 ease-out',
'text-[15px] md:text-[16px]',
)}
>
{goal}
</p>
</button>
)}
<div
className={cn(
'overflow-hidden transition-[max-height,opacity,transform,margin] duration-220 ease-out motion-reduce:transition-none',
isExpanded
? 'mt-3 max-h-40 translate-y-0 opacity-100'
: 'max-h-0 -translate-y-1 opacity-0',
)}
aria-hidden={!isExpanded}
>
{normalizedMicroStep ? (
<div className="flex items-center gap-3 border-t border-white/10 pt-3">
<button
type="button"
onClick={showActions ? onMicroStepDone : undefined}
disabled={!showActions}
className={cn(microGlyphClass, 'shrink-0 bg-black/12')}
aria-label={copy.space.focusHud.microStepCompleteAriaLabel}
>
<svg
aria-hidden
viewBox="0 0 16 16"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
>
<path d="M4 8.4 6.7 11 12 5.7" />
</svg>
</button>
<p className="min-w-0 flex-1 truncate text-left text-[14px] text-white/80">
{normalizedMicroStep}
</p>
</div>
) : (
<p className="border-t border-white/10 pt-3 text-[14px] leading-[1.5] text-white/56">
{copy.space.focusHud.refocusDescription}
</p>
)}
{showActions && canComplete ? (
<div className="mt-4 flex items-center justify-end border-t border-white/10 pt-3">
<button
type="button"
onClick={onGoalCompleteRequest}
className="text-[12px] font-medium tracking-[0.08em] text-white/62 underline decoration-white/10 underline-offset-4 transition-colors hover:text-white/84 hover:decoration-white/22"
>
{copy.space.focusHud.completeAction}
</button>
</div>
) : null}
</div>
</div>
</section>
</div>
);
};

View File

@@ -1,129 +0,0 @@
'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_REVEAL_BASE,
HUD_REVEAL_HIDDEN,
HUD_REVEAL_NEXT_BEAT,
HUD_TRAY_CONTENT,
HUD_TRAY_HAIRLINE,
HUD_TRAY_LAYER,
HUD_TRAY_SHELL,
} from './overlayStyles';
interface NextMicroStepPromptProps {
open: boolean;
goal: string;
isSubmitting: boolean;
error: string | null;
onKeepGoalOnly: () => void;
onDefineNext: () => void;
onFinish: () => void;
}
export const NextMicroStepPrompt = ({
open,
goal,
isSubmitting,
error,
onKeepGoalOnly,
onDefineNext,
onFinish,
}: NextMicroStepPromptProps) => {
const trimmedGoal = goal.trim();
return (
<div
className={cn(
HUD_REVEAL_BASE,
open ? HUD_REVEAL_NEXT_BEAT : HUD_REVEAL_HIDDEN,
)}
aria-hidden={!open}
>
<section className={HUD_TRAY_SHELL}>
<div aria-hidden className={HUD_TRAY_LAYER} />
<div aria-hidden className={HUD_TRAY_HAIRLINE} />
<div className={HUD_TRAY_CONTENT}>
<p className="text-[11px] font-medium tracking-[0.08em] text-white/42">
{copy.space.focusHud.microStepPromptEyebrow}
</p>
<h3 className="mt-1 max-w-[22rem] text-[1rem] font-medium tracking-tight text-white/94">
{copy.space.focusHud.microStepPromptTitle}
</h3>
<p className="mt-1 max-w-[21rem] text-[12px] leading-[1.55] text-white/56">
{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-4 space-y-2.5 border-t border-white/8 pt-4">
<button
type="button"
onClick={onKeepGoalOnly}
disabled={isSubmitting}
className={HUD_OPTION_ROW}
>
<div className="max-w-[20.5rem]">
<p className="text-[13px] font-medium tracking-[0.01em] text-white/78">
{copy.space.focusHud.microStepPromptKeep}
</p>
<p className="mt-1 text-[12px] leading-[1.55] text-white/44">
{copy.space.focusHud.microStepPromptKeepHint}
</p>
</div>
<span aria-hidden className={cn(HUD_OPTION_CHEVRON, 'mt-[2px]')}></span>
</button>
<button
type="button"
onClick={onDefineNext}
disabled={isSubmitting}
className={cn(HUD_OPTION_ROW, HUD_OPTION_ROW_PRIMARY)}
>
<div className="max-w-[20.5rem]">
<p className="text-[13px] font-semibold tracking-[0.01em] text-white/90">
{copy.space.focusHud.microStepPromptDefine}
</p>
<p className="mt-1 text-[12px] leading-[1.55] text-white/48">
{copy.space.focusHud.microStepPromptDefineHint}
</p>
</div>
<span aria-hidden className={cn(HUD_OPTION_CHEVRON, 'mt-[2px]')}></span>
</button>
</div>
<div className="mt-4 border-t border-white/8 pt-3">
<button
type="button"
onClick={onFinish}
disabled={isSubmitting}
className="text-[12px] font-medium tracking-[0.08em] text-white/58 underline decoration-white/12 underline-offset-4 transition-colors hover:text-white/82 hover:decoration-white/24 disabled:cursor-default disabled:text-white/26 disabled:no-underline"
>
{copy.space.focusHud.microStepPromptFinish}
</button>
<p className="mt-1.5 max-w-[20.5rem] text-[12px] leading-[1.55] text-white/42">
{copy.space.focusHud.microStepPromptFinishHint}
</p>
</div>
</div>
</section>
</div>
);
};

View File

@@ -1,111 +0,0 @@
'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_REVEAL_BASE,
HUD_REVEAL_HIDDEN,
HUD_REVEAL_PAUSE,
HUD_TRAY_CONTENT,
HUD_TRAY_HAIRLINE,
HUD_TRAY_LAYER,
HUD_TRAY_SHELL,
} from './overlayStyles';
interface PauseRefocusPromptProps {
open: boolean;
isBusy: boolean;
onRefocus: () => void;
onKeepCurrent: () => void;
onFinish: () => void;
}
export const PauseRefocusPrompt = ({
open,
isBusy,
onRefocus,
onKeepCurrent,
onFinish,
}: PauseRefocusPromptProps) => {
return (
<div
className={cn(
HUD_REVEAL_BASE,
open ? HUD_REVEAL_PAUSE : HUD_REVEAL_HIDDEN,
)}
aria-hidden={!open}
>
<section className={HUD_TRAY_SHELL}>
<div aria-hidden className={HUD_TRAY_LAYER} />
<div aria-hidden className={HUD_TRAY_HAIRLINE} />
<div className={HUD_TRAY_CONTENT}>
<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 max-w-[20.5rem]">
<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 text-[12px] leading-[1.55] text-white/50">
{copy.space.focusHud.pausePromptRefocusHint}
</p>
</div>
<span aria-hidden className={cn(HUD_OPTION_CHEVRON, 'mt-[2px]')}></span>
</button>
<button
type="button"
onClick={onKeepCurrent}
disabled={isBusy}
className={HUD_OPTION_ROW}
>
<div className="min-w-0 max-w-[20.5rem]">
<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 text-[12px] leading-[1.55] text-white/46">
{copy.space.focusHud.pausePromptKeepHint}
</p>
</div>
<span aria-hidden className={cn(HUD_OPTION_CHEVRON, 'mt-[2px]')}></span>
</button>
</div>
<div className="mt-4 border-t border-white/8 pt-3">
<button
type="button"
onClick={onFinish}
disabled={isBusy}
className="text-[12px] font-medium tracking-[0.08em] text-white/58 underline decoration-white/12 underline-offset-4 transition-colors hover:text-white/82 hover:decoration-white/24 disabled:cursor-default disabled:text-white/26 disabled:no-underline"
>
{copy.space.focusHud.pausePromptFinish}
</button>
<p className="mt-1.5 max-w-[20.5rem] text-[12px] leading-[1.55] text-white/42">
{copy.space.focusHud.pausePromptFinishHint}
</p>
</div>
</div>
</section>
</div>
);
};

View File

@@ -1,172 +0,0 @@
'use client';
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;
onMicroStepChange: (value: string) => void;
onClose: () => void;
onSubmit: () => void;
}
export const RefocusSheet = ({
open,
goalDraft,
microStepDraft,
autoFocusField,
submitLabel,
isSaving,
error,
onGoalChange,
onMicroStepChange,
onClose,
onSubmit,
}: RefocusSheetProps) => {
const goalRef = useRef<HTMLInputElement | null>(null);
const microStepRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!open) {
return;
}
const rafId = window.requestAnimationFrame(() => {
if (autoFocusField === 'microStep') {
microStepRef.current?.focus();
microStepRef.current?.select();
return;
}
goalRef.current?.focus();
goalRef.current?.select();
});
return () => {
window.cancelAnimationFrame(rafId);
};
}, [autoFocusField, open]);
useEffect(() => {
if (!open) {
return;
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !isSaving) {
onClose();
}
};
window.addEventListener('keydown', handleEscape);
return () => {
window.removeEventListener('keydown', handleEscape);
};
}, [isSaving, onClose, open]);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isSaving || goalDraft.trim().length === 0) {
return;
}
onSubmit();
};
return (
<div
className={cn(
'pointer-events-none w-full overflow-hidden transition-all duration-300 ease-out motion-reduce:duration-0',
open
? 'max-h-[32rem] 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} />
<header className="relative px-5 pt-4">
<div className="min-w-0">
<p className="text-[11px] font-medium tracking-[0.08em] text-white/42"> </p>
<h3 className="mt-1 text-[0.98rem] font-medium tracking-tight text-white/95">
{copy.space.focusHud.refocusTitle}
</h3>
<p className="mt-1 text-[13px] text-white/56">{copy.space.focusHud.refocusDescription}</p>
</div>
</header>
<form className="relative mt-5 space-y-4 px-5 pb-5" onSubmit={handleSubmit}>
<label className="block">
<span className="mb-2 block text-[11px] font-medium uppercase tracking-[0.22em] text-white/44">
{copy.space.focusHud.intentLabel}
</span>
<input
ref={goalRef}
value={goalDraft}
onChange={(event) => onGoalChange(event.target.value)}
placeholder={copy.space.sessionGoal.placeholder}
className={HUD_FIELD}
/>
</label>
<label className="block">
<span className="mb-2 block text-[11px] font-medium uppercase tracking-[0.22em] text-white/44">
{copy.space.focusHud.microStepLabel}
</span>
<input
ref={microStepRef}
value={microStepDraft}
onChange={(event) => onMicroStepChange(event.target.value)}
placeholder={copy.space.sessionGoal.hint}
className={HUD_FIELD}
/>
</label>
{error ? (
<p className="rounded-[18px] border border-rose-300/12 bg-rose-300/8 px-3 py-2 text-[12px] text-rose-100/86">
{error}
</p>
) : null}
<footer className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
disabled={isSaving}
className={HUD_TEXT_LINK}
>
{copy.common.cancel}
</button>
<button
type="submit"
disabled={isSaving || goalDraft.trim().length === 0}
className={HUD_TEXT_LINK_STRONG}
>
{isSaving ? copy.space.focusHud.refocusApplying : submitLabel ?? copy.space.focusHud.refocusApply}
</button>
</footer>
</form>
</section>
</div>
);
};

View File

@@ -1,12 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { copy } from '@/shared/i18n';
import { cn } from '@/shared/lib/cn';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
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 { InlineMicrostep } from './InlineMicrostep';
import { ReturnPrompt } from './ReturnPrompt';
interface SpaceFocusHudWidgetProps {
@@ -56,25 +54,15 @@ export const SpaceFocusHudWidget = ({
onGoalFinish,
onStatusMessage,
}: SpaceFocusHudWidgetProps) => {
const [overlay, setOverlay] = useState<'none' | 'paused' | 'return' | 'refocus' | 'next-beat' | 'complete'>('none');
const [refocusOrigin, setRefocusOrigin] = useState<'manual' | 'pause' | 'next-beat' | 'return'>('manual');
const [overlay, setOverlay] = useState<'none' | 'return' | 'complete'>('none');
const [completePreferredView, setCompletePreferredView] = useState<'choice' | 'next'>('choice');
const [draftGoal, setDraftGoal] = useState('');
const [draftMicroStep, setDraftMicroStep] = useState('');
const [autoFocusField, setAutoFocusField] = useState<'goal' | 'microStep'>('goal');
const [isSavingIntent, setSavingIntent] = useState(false);
const [intentError, setIntentError] = useState<string | null>(null);
const visibleRef = useRef(false);
const resumePlaybackStateRef = useRef<'running' | 'paused'>(playbackState);
const pausePlaybackStateRef = useRef<'running' | 'paused'>(playbackState);
const suppressNextPausePromptRef = useRef(false);
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 isPausedPromptOpen = overlay === 'paused';
const isReturnPromptOpen = overlay === 'return';
const isRefocusOpen = overlay === 'refocus';
const isMicroStepPromptOpen = overlay === 'next-beat';
const isCompleteOpen = overlay === 'complete';
const isIntentOverlayOpen = overlay !== 'none';
@@ -90,9 +78,7 @@ export const SpaceFocusHudWidget = ({
useEffect(() => {
if (!hasActiveSession) {
setOverlay('none');
setIntentError(null);
setSavingIntent(false);
setRefocusOrigin('manual');
setCompletePreferredView('choice');
}
}, [hasActiveSession]);
@@ -109,7 +95,6 @@ export const SpaceFocusHudWidget = ({
return;
}
setIntentError(null);
setOverlay('return');
}, [overlay, returnPromptMode]);
@@ -133,172 +118,94 @@ export const SpaceFocusHudWidget = ({
resumePlaybackStateRef.current = playbackState;
}, [normalizedGoal, onStatusMessage, playbackState]);
const openRefocus = useCallback((
field: 'goal' | 'microStep' = 'goal',
origin: 'manual' | 'pause' | 'next-beat' | 'return' = 'manual',
) => {
setDraftGoal(goal.trim());
setDraftMicroStep(normalizedMicroStep ?? '');
setAutoFocusField(field);
setIntentError(null);
setRefocusOrigin(origin);
setOverlay('refocus');
}, [goal, normalizedMicroStep]);
useEffect(() => {
if (
pausePlaybackStateRef.current === 'running' &&
playbackState === 'paused' &&
hasActiveSession &&
overlay === 'none'
) {
if (suppressNextPausePromptRef.current) {
suppressNextPausePromptRef.current = false;
pausePlaybackStateRef.current = playbackState;
return;
}
setIntentError(null);
setOverlay('paused');
onStatusMessage({
message: copy.space.focusHud.refocusOpenOnPause,
});
}
pausePlaybackStateRef.current = playbackState;
}, [hasActiveSession, onStatusMessage, overlay, playbackState]);
useEffect(() => {
if (playbackState === 'running' && overlay === 'paused') {
setOverlay('none');
}
}, [overlay, playbackState]);
useEffect(() => {
if (!normalizedMicroStep && overlay === 'next-beat') {
setOverlay('none');
}
}, [normalizedMicroStep, overlay]);
useEffect(() => {
if (entryOverlayIntent !== 'resume-refocus' || !hasActiveSession || overlay !== 'none') {
return;
}
openRefocus('microStep', 'manual');
// With inline microsteps, we just handle the intent and let the user click if they want.
onEntryOverlayIntentHandled?.();
}, [entryOverlayIntent, hasActiveSession, onEntryOverlayIntentHandled, openRefocus, overlay]);
}, [entryOverlayIntent, hasActiveSession, onEntryOverlayIntentHandled, overlay]);
const handleOpenCompleteSheet = (preferredView: 'choice' | 'next' = 'choice') => {
setIntentError(null);
setCompletePreferredView(preferredView);
setOverlay('complete');
};
const handleDismissReturnPrompt = () => {
onDismissReturnPrompt?.();
if (overlay === 'return') {
setOverlay('none');
}
};
const handleRefocusSubmit = async () => {
const trimmedGoal = draftGoal.trim();
if (!trimmedGoal || isSavingIntent) {
return;
}
const handleInlineMicrostepUpdate = async (nextStep: string | null) => {
if (isSavingIntent) return false;
setSavingIntent(true);
setIntentError(null);
try {
const didUpdate = await onIntentUpdate({
goal: trimmedGoal,
microStep: draftMicroStep.trim() || null,
});
if (!didUpdate) {
setIntentError(copy.space.workspace.intentSyncFailed);
return;
}
setOverlay('none');
onStatusMessage({
message: copy.space.focusHud.refocusSaved,
});
if (refocusOrigin === 'return') {
onDismissReturnPrompt?.();
}
if (refocusOrigin === 'pause' && playbackState === 'paused') {
onStartRequested?.();
const didUpdate = await onIntentUpdate({ microStep: nextStep });
if (didUpdate) {
if (nextStep) {
onStatusMessage({ message: copy.space.focusHud.refocusSaved });
} else {
onStatusMessage({ message: copy.space.focusHud.microStepCleared });
}
}
return didUpdate;
} finally {
setSavingIntent(false);
}
};
const handleKeepGoalOnly = async () => {
if (isSavingIntent) {
return;
}
setSavingIntent(true);
setIntentError(null);
try {
const didUpdate = await onIntentUpdate({
microStep: null,
});
if (!didUpdate) {
setIntentError(copy.space.workspace.intentSyncFailed);
return;
}
setOverlay('none');
onStatusMessage({
message: copy.space.focusHud.microStepCleared,
});
} finally {
setSavingIntent(false);
}
};
const handleDefineNextMicroStep = () => {
setDraftGoal(goal.trim());
setDraftMicroStep('');
setAutoFocusField('microStep');
setIntentError(null);
setRefocusOrigin('next-beat');
setOverlay('refocus');
};
return (
<>
<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
key={isIntentOverlayOpen ? 'intent-locked' : 'intent-interactive'}
goal={normalizedGoal}
microStep={microStep}
canRefocus={Boolean(hasActiveSession)}
canComplete={hasActiveSession && (sessionPhase === 'focus' || sessionPhase === 'break')}
showActions={!isIntentOverlayOpen}
onOpenRefocus={() => openRefocus('goal', 'manual')}
onMicroStepDone={() => {
if (!normalizedMicroStep) {
openRefocus('microStep', 'next-beat');
return;
}
<div className="pointer-events-none fixed inset-0 z-20 flex flex-col items-center justify-center pt-10 pb-32">
{/* The Monolith (Central Hub) */}
<div className={cn(
"pointer-events-auto flex flex-col items-center text-center max-w-4xl px-6 transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)]",
isIntentOverlayOpen ? "opacity-0 scale-95 blur-md" : "opacity-100 scale-100 blur-0"
)}>
{/* Massive Central Timer */}
<div className="relative group cursor-pointer" onClick={() => playbackState === 'running' ? onPauseRequested?.() : onStartRequested?.()}>
<p className={cn(
"text-[8rem] md:text-[14rem] font-light tracking-tighter leading-none transition-colors duration-500",
sessionPhase === 'break' ? "text-emerald-300 drop-shadow-[0_0_40px_rgba(16,185,129,0.3)]" : "text-white drop-shadow-[0_0_40px_rgba(255,255,255,0.15)]",
playbackState === 'paused' && "opacity-60"
)}>
{timeDisplay}
</p>
</div>
setIntentError(null);
setOverlay('next-beat');
}}
onGoalCompleteRequest={handleOpenCompleteSheet}
/>
{/* Core Intent */}
<div className="mt-8 flex flex-col items-center group w-full">
{/* Immutable Goal */}
<h2 className="text-2xl md:text-4xl font-light tracking-tight text-white/95">
{normalizedGoal}
</h2>
{/* Kinetic Inline Microstep */}
<div className="mt-8 flex flex-col items-center w-full max-w-lg min-h-[4rem]">
<InlineMicrostep
microStep={microStep ?? null}
isBusy={isSavingIntent}
onUpdate={handleInlineMicrostepUpdate}
/>
</div>
{hasActiveSession && (sessionPhase === 'focus' || sessionPhase === 'break') && (
<button
type="button"
onClick={() => handleOpenCompleteSheet('choice')}
className="mt-8 text-[11px] font-bold uppercase tracking-[0.25em] text-white/30 transition hover:text-white/70"
>
End Session
</button>
)}
</div>
</div>
</div>
<div className="pointer-events-none fixed inset-0 z-30">
<ReturnPrompt
open={isReturnPromptOpen && Boolean(returnPromptMode)}
mode={returnPromptMode === 'break' ? 'break' : 'focus'}
@@ -308,7 +215,6 @@ export const SpaceFocusHudWidget = ({
}}
onRefocus={() => {
handleDismissReturnPrompt();
openRefocus('microStep', 'return');
}}
onRest={() => {
handleDismissReturnPrompt();
@@ -322,59 +228,6 @@ export const SpaceFocusHudWidget = ({
handleOpenCompleteSheet('choice');
}}
/>
<PauseRefocusPrompt
open={isPausedPromptOpen}
isBusy={isSavingIntent}
onRefocus={() => openRefocus('microStep', 'pause')}
onKeepCurrent={() => {
setOverlay('none');
onStartRequested?.();
}}
onFinish={() => {
setOverlay('none');
handleOpenCompleteSheet('choice');
}}
/>
<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}
onMicroStepChange={setDraftMicroStep}
onClose={() => {
if (isSavingIntent) {
return;
}
setIntentError(null);
setOverlay('none');
}}
onSubmit={() => {
void handleRefocusSubmit();
}}
/>
<NextMicroStepPrompt
open={isMicroStepPromptOpen}
goal={normalizedGoal}
isSubmitting={isSavingIntent}
error={intentError}
onKeepGoalOnly={() => {
void handleKeepGoalOnly();
}}
onDefineNext={handleDefineNextMicroStep}
onFinish={() => {
setIntentError(null);
handleOpenCompleteSheet('choice');
}}
/>
<GoalCompleteSheet
open={isCompleteOpen}
currentGoal={goal}
@@ -383,7 +236,6 @@ export const SpaceFocusHudWidget = ({
onFinish={() => Promise.resolve(onGoalFinish())}
onRest={() => {
setOverlay('none');
suppressNextPausePromptRef.current = true;
onPauseRequested?.();
if (restReminderTimerRef.current) {
@@ -400,9 +252,8 @@ export const SpaceFocusHudWidget = ({
}}
/>
</div>
<SpaceTimerHudWidget
timeDisplay={timeDisplay}
isImmersionMode
hasActiveSession={hasActiveSession}
sessionPhase={sessionPhase}
playbackState={playbackState}
@@ -410,7 +261,6 @@ export const SpaceFocusHudWidget = ({
canStart={canStartSession}
canPause={canPauseSession}
canReset={canRestartSession}
className="pr-[4.2rem]"
onStartClick={onStartRequested}
onPauseClick={onPauseRequested}
onResetClick={onRestartRequested}

View File

@@ -9,13 +9,11 @@ import {
} from '@/features/restart-30s';
interface SpaceTimerHudWidgetProps {
timeDisplay?: string;
className?: string;
hasActiveSession?: boolean;
sessionPhase?: 'focus' | 'break' | null;
playbackState?: 'running' | 'paused' | null;
isControlsDisabled?: boolean;
isImmersionMode?: boolean;
canStart?: boolean;
canPause?: boolean;
canReset?: boolean;
@@ -27,13 +25,11 @@ interface SpaceTimerHudWidgetProps {
const HUD_ACTIONS = copy.space.timerHud.actions;
export const SpaceTimerHudWidget = ({
timeDisplay = '25:00',
className,
hasActiveSession = false,
sessionPhase = 'focus',
playbackState = 'paused',
isControlsDisabled = false,
isImmersionMode = false,
canStart = true,
canPause = false,
canReset = false,
@@ -54,45 +50,32 @@ export const SpaceTimerHudWidget = ({
return (
<div
className={cn(
'pointer-events-none fixed inset-x-0 z-20 flex justify-center px-4 sm:px-6',
'pointer-events-none fixed inset-x-0 z-20 flex justify-center px-4 transition-opacity duration-500',
className,
)}
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 2rem)' }}
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + 2.5rem)' }}
>
<div className="relative pointer-events-auto">
<div className="relative pointer-events-auto opacity-40 hover:opacity-100 transition-opacity duration-300">
<section
className={cn(
'relative z-10 flex h-[3.5rem] items-center justify-between gap-6 overflow-hidden rounded-full px-5 transition-colors',
isImmersionMode
? isBreakPhase
? 'border border-emerald-200/14 bg-[rgba(10,20,18,0.26)] backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.10)]'
: 'border border-white/10 bg-black/20 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.12)]'
: isBreakPhase
? 'border border-emerald-200/16 bg-[rgba(10,20,18,0.32)] backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.14)]'
: 'border border-white/15 bg-black/30 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.16)]',
'relative z-10 flex h-[3.5rem] items-center gap-4 overflow-hidden rounded-full pl-6 pr-4 transition-colors',
isBreakPhase
? 'border border-emerald-200/10 bg-[rgba(10,20,18,0.3)] backdrop-blur-2xl shadow-2xl'
: 'border border-white/5 bg-black/40 backdrop-blur-2xl shadow-2xl hover:border-white/10'
)}
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 pr-2 border-r border-white/10">
<span
className={cn(
'w-14 text-right text-[10px] font-bold uppercase tracking-[0.15em] opacity-80',
sessionPhase === 'break' ? 'text-emerald-300' : 'text-brand-primary'
'text-[10px] font-bold uppercase tracking-[0.2em] opacity-80',
sessionPhase === 'break' ? 'text-emerald-300' : 'text-white/60'
)}
>
{modeLabel}
</span>
<span className="w-[1px] h-4 bg-white/10" />
<span
className={cn(
'w-20 text-[1.4rem] font-medium tracking-tight text-center',
isImmersionMode ? 'text-white/90' : 'text-white',
)}
>
{timeDisplay}
</span>
</div>
<div className="flex items-center gap-1.5 pl-2 border-l border-white/10">
<div className="flex items-center gap-1">
{HUD_ACTIONS.map((action) => {
const isStartAction = action.id === 'start';
const isPauseAction = action.id === 'pause';
@@ -117,20 +100,11 @@ export const SpaceTimerHudWidget = ({
if (isResetAction) onResetClick?.();
}}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-full text-sm transition-all duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 active:scale-95 disabled:cursor-not-allowed disabled:opacity-30',
isImmersionMode
? isBreakPhase
? 'text-white/74 hover:bg-emerald-100/10 hover:text-white'
: 'text-white/70 hover:bg-white/10 hover:text-white'
: isBreakPhase
? 'text-white/82 hover:bg-emerald-100/12 hover:text-white'
: 'text-white/80 hover:bg-white/15 hover:text-white',
isStartAction && isHighlighted
? isBreakPhase
? 'bg-emerald-100/10 text-white shadow-sm'
: 'bg-white/10 text-white shadow-sm'
: '',
isPauseAction && isHighlighted
'inline-flex h-9 w-9 items-center justify-center rounded-full text-sm transition-all duration-300 ease-out focus-visible:outline-none focus-visible:ring-2 active:scale-95 disabled:cursor-not-allowed disabled:opacity-20',
isBreakPhase
? 'text-white/70 hover:bg-emerald-100/10 hover:text-white'
: 'text-white/60 hover:bg-white/10 hover:text-white',
isHighlighted
? isBreakPhase
? 'bg-emerald-100/10 text-white shadow-sm'
: 'bg-white/10 text-white shadow-sm'
@@ -144,7 +118,7 @@ export const SpaceTimerHudWidget = ({
})}
<Restart30sAction
onTrigger={triggerRestart}
className="h-8 w-8 ml-1"
className="h-9 w-9 ml-1"
/>
</div>
</section>

View File

@@ -59,41 +59,51 @@ export const FocusRightRail = ({
isIdle ? 'opacity-0 translate-x-4 pointer-events-none' : 'opacity-100 translate-x-0',
)}
>
<div className="relative flex flex-col gap-2 rounded-full border border-white/10 bg-black/20 p-2.5 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.2)]">
<div className="relative flex flex-col items-center gap-3">
{/* Notes Toggle */}
<div className="relative group">
{/* Thought Orb (Brain Dump) */}
<div className="relative group mb-4">
<button
type="button"
aria-label={copy.space.toolsDock.notesButton}
onClick={onToggleNotes}
className={cn(
"relative inline-flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200",
openPopover === 'notes' ? "bg-white/20 text-white shadow-inner" : "bg-transparent text-white/70 hover:bg-white/10 hover:text-white"
"relative inline-flex h-12 w-12 items-center justify-center rounded-full transition-all duration-500",
openPopover === 'notes'
? "bg-white text-black shadow-[0_0_40px_rgba(255,255,255,0.4)] scale-110"
: "bg-gradient-to-tr from-white/10 to-white/5 border border-white/10 text-white/90 hover:bg-white/20 hover:scale-105 hover:shadow-[0_0_20px_rgba(255,255,255,0.15)] backdrop-blur-md"
)}
>
{ANCHOR_ICON.notes}
<div className={cn("absolute inset-0 rounded-full bg-white/20 blur-md transition-opacity duration-500", openPopover === 'notes' ? "opacity-100" : "opacity-0 group-hover:opacity-50")} />
<svg viewBox="0 0 24 24" className="h-5 w-5 relative z-10" fill="none" stroke="currentColor" strokeWidth="1.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</button>
{/* Tooltip */}
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-3 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-black/60 backdrop-blur-md text-white text-[11px] px-2 py-1 rounded-md whitespace-nowrap">
{copy.space.toolsDock.notesButton}
<span className="bg-white/10 backdrop-blur-md border border-white/10 text-white text-[10px] uppercase tracking-widest px-3 py-1.5 rounded-full whitespace-nowrap shadow-xl">
Brain Dump
</span>
</div>
{openPopover === 'notes' ? (
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-4">
<QuickNotesPopover
noteDraft={noteDraft}
onDraftChange={onNoteDraftChange}
onDraftEnter={onNoteSubmit}
onSubmit={onNoteSubmit}
/>
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-6 w-[20rem]">
<div className="overflow-hidden rounded-[2rem] border border-white/10 bg-[linear-gradient(145deg,rgba(255,255,255,0.06)_0%,rgba(255,255,255,0.01)_100%)] p-2 backdrop-blur-3xl shadow-2xl">
<QuickNotesPopover
noteDraft={noteDraft}
onDraftChange={onNoteDraftChange}
onDraftEnter={onNoteSubmit}
onSubmit={onNoteSubmit}
/>
</div>
</div>
) : null}
</div>
{/* Standard Tools */}
<div className="relative flex flex-col gap-2 rounded-full border border-white/10 bg-black/20 p-2.5 backdrop-blur-2xl shadow-[0_8px_32px_rgba(0,0,0,0.2)]">
{/* Sound Toggle */}
<div className="relative group">
<button
@@ -174,6 +184,8 @@ export const FocusRightRail = ({
</div>
</div>
</div>
</div>
</div>
);