From 2ac568a4ab516a2e3f0de48761bef7cb79cf6143 Mon Sep 17 00:00:00 2001 From: corpi Date: Fri, 6 Mar 2026 01:41:12 +0900 Subject: [PATCH] =?UTF-8?q?style(space):=20=EC=83=81=EB=8B=A8=20=ED=86=A0?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=81=AC=EA=B8=B0=C2=B7=ED=8C=A8=EB=94=A9?= =?UTF-8?q?=C2=B7=ED=83=80=EC=9D=B4=ED=8F=AC=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/work.md | 129 +++++++++++++++++- .../space-workspace/ui/FocusTopToast.tsx | 4 +- .../ui/SpaceWorkspaceWidget.tsx | 70 +++++++--- 3 files changed, 183 insertions(+), 20 deletions(-) diff --git a/docs/work.md b/docs/work.md index 1d31eb8..a35f2ad 100644 --- a/docs/work.md +++ b/docs/work.md @@ -1,2 +1,127 @@ -프로필 왼쪽의 등급 칩의 글자가 너무 작다. -실제로 돈을 낸 사람들이 대우를 받을 수 있다는 느낌이 들도록 좀더 세련된 느낌을 줘야한다. +# Work Order + +이 파일은 이번 세션에서 처리할 작업을 적는 실행 입력서다. + +## 작성 규칙 + +- 작업은 가능한 한 "주제별"로 분리해서 작성한다. +- 한 주제는 가능하면 한 커밋으로 끝낼 수 있게 범위를 좁힌다. +- "금지사항/제외 범위"를 명시해서 불필요한 변경을 막는다. + +## 우선순위 + +- 위에서 아래 순서대로 높은 우선순위로 간주한다. +- `작업 1`을 먼저 처리하고, 완료 시 다음 작업으로 넘어간다. + +--- + +## 작업 1 + +- 제목: 코어 루프 완성 — Goal Complete Sheet(다음 한 조각 입력) 마감 +- 목적: + - 이 앱의 재방문/체감은 “완료 → 다음 목표”가 자연스럽게 이어질 때 생긴다. + - Focus 화면에서 목표 완료가 폼 UI처럼 보이지 않도록 하고, 완료 후 다음 한 조각 입력 플로우를 프리미엄스럽게 만든다. +- 변경 범위: + - Focus HUD의 목표는 “1줄 앵커”로 유지(상시 큰 카드 금지) + - 완료 트리거(1개만 선택해 고정): + - Goal 1줄 앵커 롱프레스(1초) 또는 작은 ghost ‘완료’(체크박스 금지) + - 완료 시 Goal Complete Sheet 표시(하단 시트) + - 타이틀 + 입력 1개 + 추천 칩 4개 + CTA 2개(바로 다음 조각 시작 / 잠깐 쉬기) + - Primary 클릭 시 다음 목표로 교체(더미) + 시트 닫기 + - Secondary는 Break(더미) 또는 토스트 + 시트 닫기 + - 전역 블러/딤 금지, 모션 200~300ms 저자극 +- 제외 범위: + - 서버/DB/통계/실제 타이머 로직 구현 금지 +- 완료 조건: + - 완료 → 다음 목표 입력 → 바로 시작이 2~3스텝 내로 끝난다. +- 검증: + - npx tsc --noEmit +- 커밋 힌트: + - feat(goal): Goal Complete Sheet로 다음 한 조각 루프 완성 + +--- + +## 작업 2 + +- 제목: 세션 이어가기(Resume) — 새로고침/재진입 시 “지난 한 조각 이어서” +- 목적: + - 출시 전이라도 “다시 들어왔을 때 바로 이어서 시작”이 되면 사용성이 급격히 좋아지고 재방문을 만든다. +- 변경 범위: + - 로컬 저장(더미)으로 마지막 상태를 복원: + - 마지막 목표, Scene, Timer, Sound, override flags + - /space 진입 시 “지난 한 조각 이어서”를 조용한 CTA로 제공(Setup가 아니라 Focus 진입 직전에 1회) + - 사용자가 거절하면 새 세션(목표 입력)로 + - 카피는 저자극/확정 표현 금지 +- 제외 범위: + - 로그인/서버 동기화 금지 +- 완료 조건: + - 새로고침 후에도 마지막 세션이 이어지는 것처럼 보이고, 이어서 시작이 가능하다. +- 검증: + - npx tsc --noEmit +- 커밋 힌트: + - feat(resume): 지난 세션 이어서(더미) 플로우 추가 + +--- + +## 작업 3 + +- 제목: Recover 시그니처 — Notes(쓰기 전용) → Inbox(읽기/정리) + 30초 숨고르기 정리 +- 목적: + - ADHD 타겟의 차별점은 “산만해져도 다시 돌아오는 비용”을 줄이는 것이다. + - 쓰기와 읽기/정리를 분리해 몰입을 깨지 않게 한다. +- 변경 범위: + - Notes 팝오버는 쓰기 전용(리스트/정리 버튼 제거) + - Inbox는 도크 시트에서 읽기/정리(완료/삭제 + Undo 더미) + - 30초 숨고르기(더미) 흐름 정리: + - 버튼 카피/위치/동작을 “다시 돌아오기” 느낌으로 + - 과한 UI 추가 금지 +- 제외 범위: + - 실제 타이머/오디오 로직 구현 금지 +- 완료 조건: + - Focus 중에는 쓰기만, 정리는 Inbox에서만 가능하며 복귀 흐름이 자연스럽다. +- 검증: + - npx tsc --noEmit +- 커밋 힌트: + - feat(recover): Notes→Inbox 복귀 흐름 및 30초 숨고르기 정리 + +--- + +## 작업 4 + +- 제목: Stage 폴리시 규칙 고정 + 마감(가독성/모션/레이어) +- 목적: + - Portal/LifeAt 느낌은 “미세한 마감”에서 결정된다. + - 앞선 코어 동선이 확정된 후, 가독성과 모션/레이어를 일관되게 다듬는다. +- 변경 범위: + - 밝은/어두운 배경 모두에서 HUD/앵커 가독성 안정(전역 blur 금지, 로컬 스크림 최소) + - 모션 200~300ms 저자극 통일 + - 아이콘/버튼 간격/재질 통일(글래스 톤) +- 제외 범위: + - 기능 추가 금지(스타일/레이어만) +- 완료 조건: + - 배경이 달라도 핵심 정보가 항상 읽히고, 전체가 프리미엄스럽게 정돈된다. +- 검증: + - npx tsc --noEmit +- 커밋 힌트: + - style(stage): 가독성/모션/레이어 폴리시 + +--- + +## 작업 5 + +- 제목: Pro/Paywall 최소 연결(의도 기반) — Packs/Profiles 중심 +- 목적: + - 기본 기능 잠금 없이, 확장/품질/개인화로 유료 이유를 만든다. + - Focus를 방해하지 않고 클릭 의도 기반으로만 paywall을 연다. +- 변경 범위: + - Time 같은 기본 기능 LOCK 제거 유지 + - Pro는 Scene Packs / Sound Packs / Profile 저장으로 재배치 + - Paywall Sheet(더미) 구현: 잠긴 항목 클릭 시에만 노출 +- 제외 범위: + - 실제 결제 연동 금지 +- 완료 조건: + - Pro가 “확장/팩/개인화”로 이해되고, Focus 흐름을 방해하지 않는다. +- 검증: + - npx tsc --noEmit +- 커밋 힌트: + - feat(paywall): 의도 기반 Pro 진입/Paywall(더미) 연결 diff --git a/src/widgets/space-workspace/ui/FocusTopToast.tsx b/src/widgets/space-workspace/ui/FocusTopToast.tsx index a225d91..04ed9db 100644 --- a/src/widgets/space-workspace/ui/FocusTopToast.tsx +++ b/src/widgets/space-workspace/ui/FocusTopToast.tsx @@ -26,14 +26,14 @@ export const FocusTopToast = ({ 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" + className="pointer-events-auto inline-flex min-h-10 max-w-[min(480px,94vw)] items-center gap-2.5 rounded-full border border-white/14 bg-black/36 px-4 py-2 text-sm text-white/88 shadow-[0_10px_28px_rgba(2,6,23,0.3)] backdrop-blur-md" > {message} {actionLabel ? ( diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index 0af673c..93529bc 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -143,14 +143,11 @@ const resolveInitialTimerLabel = ( export const SpaceWorkspaceWidget = () => { const searchParams = useSearchParams(); - const storedSelection = useMemo(() => readStoredWorkspaceSelection(), []); const roomQuery = searchParams.get('room'); - const goalQuery = searchParams.get('goal'); + const goalQuery = searchParams.get('goal')?.trim() ?? ''; const soundQuery = searchParams.get('sound'); const timerQuery = searchParams.get('timer'); - const storedGoal = storedSelection.goal?.trim() ?? ''; const hasQueryOverrides = Boolean(roomQuery || goalQuery || soundQuery || timerQuery); - const canOfferResume = storedGoal.length > 0 && !hasQueryOverrides; const { thoughts, thoughtCount, @@ -162,17 +159,17 @@ export const SpaceWorkspaceWidget = () => { setThoughtCompleted, } = useThoughtInbox(); - const initialRoomId = resolveInitialRoomId(roomQuery, storedSelection.sceneId); + const initialRoomId = resolveInitialRoomId(roomQuery, undefined); const initialRoom = getRoomById(initialRoomId) ?? ROOM_THEMES[0]; - const initialGoal = goalQuery?.trim() ?? ''; + const initialGoal = goalQuery; const initialSoundPresetId = resolveInitialSoundPreset( soundQuery, - storedSelection.soundPresetId, + undefined, initialRoom.recommendedSoundPresetId, ); const initialTimerLabel = resolveInitialTimerLabel( timerQuery, - storedSelection.timerPresetId, + undefined, initialRoom.recommendedTimerPresetId, ); @@ -181,10 +178,12 @@ export const SpaceWorkspaceWidget = () => { const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel); const [goalInput, setGoalInput] = useState(initialGoal); const [selectedGoalId, setSelectedGoalId] = useState(null); - const [showResumePrompt, setShowResumePrompt] = useState(canOfferResume); + const [resumeGoal, setResumeGoal] = useState(''); + const [showResumePrompt, setShowResumePrompt] = useState(false); + const [hasHydratedSelection, setHasHydratedSelection] = useState(false); const [selectionOverride, setSelectionOverride] = useState({ - sound: Boolean(storedSelection.override?.sound), - timer: Boolean(storedSelection.override?.timer), + sound: false, + timer: false, }); const { @@ -234,6 +233,41 @@ export const SpaceWorkspaceWidget = () => { } }, [selectionOverride.sound, selectionOverride.timer, setSelectedPresetId]); + useEffect(() => { + const storedSelection = readStoredWorkspaceSelection(); + const restoredSelectionOverride: SelectionOverride = { + sound: Boolean(storedSelection.override?.sound), + timer: Boolean(storedSelection.override?.timer), + }; + + setSelectionOverride(restoredSelectionOverride); + + if (!roomQuery && storedSelection.sceneId && getRoomById(storedSelection.sceneId)) { + setSelectedRoomId(storedSelection.sceneId); + } + + if (!timerQuery) { + const restoredTimerLabel = resolveTimerLabelFromPresetId(storedSelection.timerPresetId); + + if (restoredTimerLabel) { + setSelectedTimerLabel(restoredTimerLabel); + } + } + + if (!soundQuery && storedSelection.soundPresetId && SOUND_PRESETS.some((preset) => preset.id === storedSelection.soundPresetId)) { + setSelectedPresetId(storedSelection.soundPresetId); + } + + const restoredGoal = storedSelection.goal?.trim() ?? ''; + + if (!goalQuery && restoredGoal.length > 0 && !hasQueryOverrides) { + setResumeGoal(restoredGoal); + setShowResumePrompt(true); + } + + setHasHydratedSelection(true); + }, [goalQuery, hasQueryOverrides, roomQuery, setSelectedPresetId, soundQuery, timerQuery]); + useEffect(() => { applyRecommendedSelections(selectedRoomId); }, [applyRecommendedSelections, selectedRoomId]); @@ -323,11 +357,15 @@ export const SpaceWorkspaceWidget = () => { return; } + if (!hasHydratedSelection) { + return; + } + const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel); const normalizedGoal = goalInput.trim().length > 0 ? goalInput.trim() : showResumePrompt - ? storedGoal + ? resumeGoal : ''; window.localStorage.setItem( @@ -340,7 +378,7 @@ export const SpaceWorkspaceWidget = () => { override: selectionOverride, }), ); - }, [goalInput, selectedRoomId, selectedTimerLabel, selectedPresetId, selectionOverride, showResumePrompt, storedGoal]); + }, [goalInput, hasHydratedSelection, resumeGoal, selectedRoomId, selectedTimerLabel, selectedPresetId, selectionOverride, showResumePrompt]); return (
@@ -373,11 +411,11 @@ export const SpaceWorkspaceWidget = () => { onGoalChipSelect={handleGoalChipSelect} onStart={handleStart} resumeHint={ - showResumePrompt + showResumePrompt && resumeGoal ? { - goal: storedGoal, + goal: resumeGoal, onResume: () => { - setGoalInput(storedGoal); + setGoalInput(resumeGoal); setSelectedGoalId(null); setShowResumePrompt(false); setWorkspaceMode('focus');