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:
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
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 { useReducedMotion } from '@/shared/lib/useReducedMotion';
|
||||||
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
|
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
|
||||||
import { GoalCompleteSheet } from './GoalCompleteSheet';
|
import { GoalCompleteSheet } from './GoalCompleteSheet';
|
||||||
@@ -10,8 +10,6 @@ interface SpaceFocusHudWidgetProps {
|
|||||||
timerLabel: string;
|
timerLabel: string;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onGoalUpdate: (nextGoal: string) => void;
|
onGoalUpdate: (nextGoal: string) => void;
|
||||||
statusLine: HudStatusLineItem | null;
|
|
||||||
onStatusAction: () => void;
|
|
||||||
onStatusMessage: (payload: HudStatusLinePayload) => void;
|
onStatusMessage: (payload: HudStatusLinePayload) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,8 +18,6 @@ export const SpaceFocusHudWidget = ({
|
|||||||
timerLabel,
|
timerLabel,
|
||||||
visible,
|
visible,
|
||||||
onGoalUpdate,
|
onGoalUpdate,
|
||||||
statusLine,
|
|
||||||
onStatusAction,
|
|
||||||
onStatusMessage,
|
onStatusMessage,
|
||||||
}: SpaceFocusHudWidgetProps) => {
|
}: SpaceFocusHudWidgetProps) => {
|
||||||
const reducedMotion = useReducedMotion();
|
const reducedMotion = useReducedMotion();
|
||||||
@@ -100,18 +96,9 @@ export const SpaceFocusHudWidget = ({
|
|||||||
<SpaceTimerHudWidget
|
<SpaceTimerHudWidget
|
||||||
timerLabel={timerLabel}
|
timerLabel={timerLabel}
|
||||||
goal={goal}
|
goal={goal}
|
||||||
statusLine={
|
|
||||||
statusLine
|
|
||||||
? {
|
|
||||||
message: statusLine.message,
|
|
||||||
actionLabel: statusLine.action?.label,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
isImmersionMode
|
isImmersionMode
|
||||||
className="pr-[4.2rem]"
|
className="pr-[4.2rem]"
|
||||||
onGoalCompleteRequest={handleOpenCompleteSheet}
|
onGoalCompleteRequest={handleOpenCompleteSheet}
|
||||||
onStatusAction={onStatusAction}
|
|
||||||
onPlaybackStateChange={(state) => {
|
onPlaybackStateChange={(state) => {
|
||||||
if (reducedMotion) {
|
if (reducedMotion) {
|
||||||
playbackStateRef.current = state;
|
playbackStateRef.current = state;
|
||||||
|
|||||||
@@ -12,13 +12,8 @@ interface SpaceTimerHudWidgetProps {
|
|||||||
goal: string;
|
goal: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
isImmersionMode?: boolean;
|
isImmersionMode?: boolean;
|
||||||
statusLine?: {
|
|
||||||
message: string;
|
|
||||||
actionLabel?: string;
|
|
||||||
} | null;
|
|
||||||
onPlaybackStateChange?: (state: 'running' | 'paused') => void;
|
onPlaybackStateChange?: (state: 'running' | 'paused') => void;
|
||||||
onGoalCompleteRequest?: () => void;
|
onGoalCompleteRequest?: () => void;
|
||||||
onStatusAction?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const HUD_ACTIONS = [
|
const HUD_ACTIONS = [
|
||||||
@@ -32,10 +27,8 @@ export const SpaceTimerHudWidget = ({
|
|||||||
goal,
|
goal,
|
||||||
className,
|
className,
|
||||||
isImmersionMode = false,
|
isImmersionMode = false,
|
||||||
statusLine = null,
|
|
||||||
onPlaybackStateChange,
|
onPlaybackStateChange,
|
||||||
onGoalCompleteRequest,
|
onGoalCompleteRequest,
|
||||||
onStatusAction,
|
|
||||||
}: SpaceTimerHudWidgetProps) => {
|
}: SpaceTimerHudWidgetProps) => {
|
||||||
const { isBreatheMode, triggerRestart } = useRestart30s();
|
const { isBreatheMode, triggerRestart } = useRestart30s();
|
||||||
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.';
|
const normalizedGoal = goal.trim().length > 0 ? goal.trim() : '이번 한 조각을 설정해 주세요.';
|
||||||
@@ -132,30 +125,6 @@ export const SpaceTimerHudWidget = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
44
src/widgets/space-workspace/ui/FocusTopToast.tsx
Normal file
44
src/widgets/space-workspace/ui/FocusTopToast.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -20,6 +20,7 @@ import { useHudStatusLine } from '@/shared/lib/useHudStatusLine';
|
|||||||
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
|
import { SpaceFocusHudWidget } from '@/widgets/space-focus-hud';
|
||||||
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
|
import { SpaceSetupDrawerWidget } from '@/widgets/space-setup-drawer';
|
||||||
import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock';
|
import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock';
|
||||||
|
import { FocusTopToast } from './FocusTopToast';
|
||||||
|
|
||||||
type WorkspaceMode = 'setup' | 'focus';
|
type WorkspaceMode = 'setup' | 'focus';
|
||||||
type SelectionOverride = {
|
type SelectionOverride = {
|
||||||
@@ -378,8 +379,6 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
goal={goalInput.trim()}
|
goal={goalInput.trim()}
|
||||||
timerLabel={selectedTimerLabel}
|
timerLabel={selectedTimerLabel}
|
||||||
visible={isFocusMode}
|
visible={isFocusMode}
|
||||||
statusLine={activeStatus}
|
|
||||||
onStatusAction={runActiveAction}
|
|
||||||
onStatusMessage={pushStatusLine}
|
onStatusMessage={pushStatusLine}
|
||||||
onGoalUpdate={(nextGoal) => {
|
onGoalUpdate={(nextGoal) => {
|
||||||
setGoalInput(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
|
<SpaceToolsDockWidget
|
||||||
isFocusMode={isFocusMode}
|
isFocusMode={isFocusMode}
|
||||||
rooms={setupRooms}
|
rooms={setupRooms}
|
||||||
|
|||||||
Reference in New Issue
Block a user