refactor(feedback): Focus 토스트를 상단 중앙 단일 채널로 통합

맥락:
- Focus 화면에서 토스트 위치가 Notes 저장/Goal 완료 상황마다 달라져 가독성과 일관성이 떨어졌습니다.

변경사항:
- HUD 내부 status line 렌더를 제거하고 상단 중앙 고정 토스트 컴포넌트를 추가했습니다.
- /space 루트에서 activeStatus를 상단 중앙 토스트로만 표시하도록 피드백 채널을 단일화했습니다.
- Undo 액션은 상단 중앙 토스트 내부 링크로 동일하게 노출되도록 유지했습니다.

검증:
- npx tsc --noEmit

세션-상태: Focus 피드백 채널이 상단 중앙 1곳으로 통일됨
세션-다음: work.md 작업 1부터 Pro/플랜 잠금 정책 재구성 진행
세션-리스크: 기존 lint 규칙의 set-state-in-effect 오류는 별도 축으로 남아 있음
This commit is contained in:
2026-03-05 17:03:00 +09:00
parent f3f0518588
commit a056fc841e
4 changed files with 53 additions and 47 deletions

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HudStatusLineItem, HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import type { HudStatusLinePayload } from '@/shared/lib/useHudStatusLine';
import { useReducedMotion } from '@/shared/lib/useReducedMotion';
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
import { GoalCompleteSheet } from './GoalCompleteSheet';
@@ -10,8 +10,6 @@ interface SpaceFocusHudWidgetProps {
timerLabel: string;
visible: boolean;
onGoalUpdate: (nextGoal: string) => void;
statusLine: HudStatusLineItem | null;
onStatusAction: () => void;
onStatusMessage: (payload: HudStatusLinePayload) => void;
}
@@ -20,8 +18,6 @@ export const SpaceFocusHudWidget = ({
timerLabel,
visible,
onGoalUpdate,
statusLine,
onStatusAction,
onStatusMessage,
}: SpaceFocusHudWidgetProps) => {
const reducedMotion = useReducedMotion();
@@ -100,18 +96,9 @@ export const SpaceFocusHudWidget = ({
<SpaceTimerHudWidget
timerLabel={timerLabel}
goal={goal}
statusLine={
statusLine
? {
message: statusLine.message,
actionLabel: statusLine.action?.label,
}
: null
}
isImmersionMode
className="pr-[4.2rem]"
onGoalCompleteRequest={handleOpenCompleteSheet}
onStatusAction={onStatusAction}
onPlaybackStateChange={(state) => {
if (reducedMotion) {
playbackStateRef.current = state;

View File

@@ -12,13 +12,8 @@ interface SpaceTimerHudWidgetProps {
goal: string;
className?: string;
isImmersionMode?: boolean;
statusLine?: {
message: string;
actionLabel?: string;
} | null;
onPlaybackStateChange?: (state: 'running' | 'paused') => void;
onGoalCompleteRequest?: () => void;
onStatusAction?: () => void;
}
const HUD_ACTIONS = [
@@ -32,10 +27,8 @@ export const SpaceTimerHudWidget = ({
goal,
className,
isImmersionMode = false,
statusLine = null,
onPlaybackStateChange,
onGoalCompleteRequest,
onStatusAction,
}: SpaceTimerHudWidgetProps) => {
const { isBreatheMode, triggerRestart } = useRestart30s();
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.';
@@ -132,30 +125,6 @@ export const SpaceTimerHudWidget = ({
/>
</div>
</section>
<div
className="pointer-events-none absolute bottom-2 left-3.5 z-[12] max-w-[72%]"
role="status"
aria-live="polite"
aria-atomic="true"
>
<div
className={cn(
'inline-flex max-w-full items-center gap-1.5 rounded-full border border-white/12 bg-black/24 px-2.5 py-1 text-[10px] text-white/72 backdrop-blur-sm transition-all duration-[220ms] ease-out motion-reduce:duration-0',
statusLine ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0',
)}
>
<span className="truncate">{statusLine?.message ?? ''}</span>
{statusLine?.actionLabel ? (
<button
type="button"
onClick={onStatusAction}
className="pointer-events-auto shrink-0 text-[10px] font-medium text-white/84 underline-offset-2 transition-colors hover:text-white hover:underline"
>
{statusLine.actionLabel}
</button>
) : null}
</div>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,44 @@
import { cn } from '@/shared/lib/cn';
interface FocusTopToastProps {
visible: boolean;
message: string;
actionLabel?: string;
onAction?: () => void;
}
export const FocusTopToast = ({
visible,
message,
actionLabel,
onAction,
}: FocusTopToastProps) => {
return (
<div
className={cn(
'pointer-events-none fixed inset-x-0 z-50 flex justify-center px-3 transition-all duration-[220ms] ease-out motion-reduce:duration-0',
'top-[calc(env(safe-area-inset-top,0px)+0.75rem)]',
visible ? 'translate-y-0 opacity-100' : '-translate-y-1 opacity-0',
)}
aria-hidden={!visible}
>
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="pointer-events-auto inline-flex max-w-[min(420px,92vw)] items-center gap-2 rounded-full border border-white/14 bg-black/32 px-3 py-1.5 text-xs text-white/86 shadow-[0_8px_24px_rgba(2,6,23,0.28)] backdrop-blur-md"
>
<span className="truncate">{message}</span>
{actionLabel ? (
<button
type="button"
onClick={onAction}
className="shrink-0 text-xs font-medium text-white/92 underline underline-offset-2 transition-colors hover:text-white"
>
{actionLabel}
</button>
) : null}
</div>
</div>
);
};

View File

@@ -20,6 +20,7 @@ import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock';
import { FocusTopToast } from './FocusTopToast';
type WorkspaceMode = 'setup' | 'focus';
type SelectionOverride = {
@@ -378,8 +379,6 @@ export const SpaceWorkspaceWidget = () => {
goal={goalInput.trim()}
timerLabel={selectedTimerLabel}
visible={isFocusMode}
statusLine={activeStatus}
onStatusAction={runActiveAction}
onStatusMessage={pushStatusLine}
onGoalUpdate={(nextGoal) => {
setGoalInput(nextGoal);
@@ -387,6 +386,13 @@ export const SpaceWorkspaceWidget = () => {
}}
/>
<FocusTopToast
visible={isFocusMode && Boolean(activeStatus)}
message={activeStatus?.message ?? ''}
actionLabel={activeStatus?.action?.label}
onAction={runActiveAction}
/>
<SpaceToolsDockWidget
isFocusMode={isFocusMode}
rooms={setupRooms}