feat(core-loop): /app 진입과 /space 복구 흐름 구현
This commit is contained in:
132
docs/09_app_entry_detailed_spec.md
Normal file
132
docs/09_app_entry_detailed_spec.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# 09. `/app` Entry Detailed Spec
|
||||
|
||||
Last Updated: 2026-03-14
|
||||
|
||||
이 문서는 `/app`을 **single-goal commitment gate**로 구현할 때의 상세 기준 문서다.
|
||||
|
||||
관련 상위 기준:
|
||||
|
||||
- `../../product_principles.md`
|
||||
- `../../current_context.md`
|
||||
- `08_app_reframe_strategy.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 한 줄 정의
|
||||
|
||||
`/app`은 planner나 setup wizard가 아니라,
|
||||
**사용자가 지금 할 일 한 가지를 정하고 가장 낮은 마찰로 `/space`에 들어가게 만드는 입구**다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 화면 역할
|
||||
|
||||
### `/app`이 해야 하는 일
|
||||
|
||||
- 현재 세션이 있으면 `이어가기`를 제안한다
|
||||
- 현재 세션이 없으면 `goal 1개 + optional microStep 1개`만 받는다
|
||||
- 주 행동은 항상 `지금 시작` 하나다
|
||||
- 시작 전에 많은 설정을 요구하지 않는다
|
||||
|
||||
### `/app`이 하면 안 되는 일
|
||||
|
||||
- 여러 목표를 관리하게 하기
|
||||
- planner / to-do / list app처럼 보이게 하기
|
||||
- scene / sound / timer 선택을 메인 결정으로 끌어올리기
|
||||
- 진입 전에 `정리`, `관리`, `저장`을 강요하기
|
||||
|
||||
---
|
||||
|
||||
## 3. 정보 구조
|
||||
|
||||
### 상태 A. Current Session Exists
|
||||
|
||||
- eyebrow: `Resume`
|
||||
- 현재 goal
|
||||
- optional microStep
|
||||
- primary CTA: `이어서 들어가기`
|
||||
- 보조 정보: 현재 ritual 요약
|
||||
- 설명:
|
||||
- 새 목표는 현재 세션을 마무리한 뒤 시작할 수 있다고만 안내
|
||||
|
||||
### 상태 B. No Current Session
|
||||
|
||||
- 제목
|
||||
- 설명
|
||||
- goal input 1개
|
||||
- optional microStep input 1개
|
||||
- suggestion chips 3~4개
|
||||
- primary CTA: `지금 시작`
|
||||
- 하단 보조 정보:
|
||||
- `기본 ritual · scene · timer · sound`
|
||||
- `공간과 사운드는 들어간 뒤에도 바꿀 수 있어요.`
|
||||
|
||||
---
|
||||
|
||||
## 4. 상호작용 원칙
|
||||
|
||||
### Goal
|
||||
|
||||
- 필수
|
||||
- 한 줄 입력
|
||||
- enter로 바로 시작 가능
|
||||
|
||||
### MicroStep
|
||||
|
||||
- 선택
|
||||
- checklist가 아니라 `지금 할 한 조각`
|
||||
- 1개만 허용
|
||||
|
||||
### Suggestions
|
||||
|
||||
- 계획 리스트가 아니라 start friction reducer 역할
|
||||
- 클릭 시 goal input을 빠르게 채움
|
||||
|
||||
### Start
|
||||
|
||||
- 현재 세션이 없을 때만 가능
|
||||
- `goal + microStep + default ritual`로 세션 시작
|
||||
- 성공 시 `/space` 이동
|
||||
|
||||
### Resume
|
||||
|
||||
- 현재 세션이 있으면 start UI 대신 resume UI를 우선 노출
|
||||
- 클릭 시 `/space`로 이동
|
||||
|
||||
---
|
||||
|
||||
## 5. UX 원칙
|
||||
|
||||
- `/app`은 start surface여야지 setup surface가 아니어야 한다
|
||||
- 한 화면 안에서 결정할 것은 최대 2개(goal, optional microStep)
|
||||
- visual hierarchy는 `goal > CTA > microStep > suggestions > ritual helper` 순서
|
||||
- paywall은 메인 CTA가 아니라 Plan Pill에서만 여는 수준으로 제한
|
||||
|
||||
---
|
||||
|
||||
## 6. 구현 범위
|
||||
|
||||
### 이번 slice에 포함
|
||||
|
||||
- 2-step ritual flow 제거
|
||||
- manage/list UI를 메인 경로에서 제거
|
||||
- current session resume path 추가
|
||||
- single-goal + optional microStep direct start
|
||||
- default ritual만 사용
|
||||
|
||||
### 이번 slice에 포함하지 않음
|
||||
|
||||
- multi-goal manage 복구
|
||||
- entry 단계 scene/sound/timer 직접 선택
|
||||
- planner/dashboard 요소 복구
|
||||
- goal 저장과 session start의 backend atomic endpoint 신규 도입
|
||||
|
||||
---
|
||||
|
||||
## 7. 검증 기준
|
||||
|
||||
- 사용자가 10초 안에 goal을 입력하고 `/space`로 들어갈 수 있다
|
||||
- `/app` 첫 인상이 planner나 setup wizard처럼 보이지 않는다
|
||||
- current session이 있으면 `resume`이 주 행동으로 읽힌다
|
||||
- 현재 세션이 없을 때는 `지금 시작`이 유일한 dominant CTA다
|
||||
- scene / sound / timer는 entry의 주 의사결정으로 느껴지지 않는다
|
||||
468
docs/10_refocus_system_spec.md
Normal file
468
docs/10_refocus_system_spec.md
Normal file
@@ -0,0 +1,468 @@
|
||||
# 10. Refocus System Spec
|
||||
|
||||
Last Updated: 2026-03-14
|
||||
|
||||
이 문서는 VibeRoom의 `Refocus System`을 제품 대표 경험으로 설계하기 위한 상세 기준 문서다.
|
||||
|
||||
관련 상위 문서:
|
||||
|
||||
- `../../product_principles.md`
|
||||
- `../../current_context.md`
|
||||
- `09_app_entry_detailed_spec.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 왜 Refocus가 핵심인가
|
||||
|
||||
VibeRoom의 차별점은 `더 오래 계획하게 만드는 것`이 아니라,
|
||||
`흔들린 뒤에도 다시 집중 위에 올라타게 만드는 것`이어야 한다.
|
||||
|
||||
ADHD 성향 사용자와 프리랜서에게 진짜 어려운 순간은 대개 아래 셋 중 하나다.
|
||||
|
||||
- 시작 직전
|
||||
- 잠깐 멈췄다가 다시 붙잡아야 할 때
|
||||
- 한 조각을 끝냈는데 다음 동작이 흐려졌을 때
|
||||
|
||||
`/app`이 시작 마찰을 줄이는 화면이라면,
|
||||
`Refocus System`은 **세션 도중 무너진 흐름을 다시 복구하는 핵심 시스템**이다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 레퍼런스에서 가져올 것 / 버릴 것
|
||||
|
||||
이 문서는 2026-03-14 기준 공식 사이트를 기준으로 아래 레퍼런스를 참고했다.
|
||||
|
||||
- [Portal](https://portal.app/)
|
||||
- [LifeAt Pricing](https://lifeat.io/pricing)
|
||||
- [Focusmate Pricing](https://www.focusmate.com/pricing/)
|
||||
- [Focusmate Getting Started](https://support.focusmate.com/en/articles/9110188-getting-started)
|
||||
- [Focusmate Focus Now](https://support.focusmate.com/en/articles/9994509-focus-now)
|
||||
|
||||
### Portal에서 가져올 것
|
||||
|
||||
- 배경이 주인공이고 인터페이스는 조용히 뒤로 물러나야 한다
|
||||
- 감각 품질 자체가 제품 가치가 된다
|
||||
- “아름답다”가 장식이 아니라 사용 지속 이유가 된다
|
||||
|
||||
### Focusmate에서 가져올 것
|
||||
|
||||
- 행동을 시작시키는 명확한 구조
|
||||
- 사용자가 망설이지 않게 하는 단일 흐름
|
||||
- `지금 바로 들어간다`는 즉시성
|
||||
|
||||
### LifeAt에서 가져올 것
|
||||
|
||||
- 가볍게 시작할 수 있는 친화성
|
||||
- 공간과 집중을 연결하는 감성
|
||||
|
||||
### LifeAt에서 가져오면 안 되는 것
|
||||
|
||||
- planner / todo / calendar 중심 구조
|
||||
- 목표를 많이 다루게 하는 흐름
|
||||
- “집중 앱”보다 “정리 앱”처럼 읽히는 경험
|
||||
|
||||
---
|
||||
|
||||
## 3. 한 줄 정의
|
||||
|
||||
> Refocus는 사용자가 흔들렸을 때 죄책감 없이 다시 한 조각 위에 올라타게 만드는 조용한 복귀 의식이다.
|
||||
|
||||
중요한 점:
|
||||
|
||||
- `수정 기능`이 아니다
|
||||
- `체크리스트`가 아니다
|
||||
- `왜 못 했는지 묻는 평가 시스템`이 아니다
|
||||
|
||||
---
|
||||
|
||||
## 4. 제품 목표
|
||||
|
||||
Refocus System은 아래 4가지를 만족해야 한다.
|
||||
|
||||
1. 사용자가 세션을 포기하지 않게 한다
|
||||
2. pause 이후 복귀 마찰을 줄인다
|
||||
3. microStep을 다시 잡아 실행을 재개하게 한다
|
||||
4. UI가 배경과 몰입을 방해하지 않는다
|
||||
|
||||
---
|
||||
|
||||
## 5. 시스템 원칙
|
||||
|
||||
### 1. Refocus는 한 번에 한 질문만 한다
|
||||
|
||||
질문을 여러 개 던지면 planner처럼 느껴진다.
|
||||
항상 다음 하나만 물어야 한다.
|
||||
|
||||
- 계속할까?
|
||||
- 지금 할 한 조각은 무엇일까?
|
||||
- 이 목표를 끝낼까?
|
||||
|
||||
### 2. 사용자를 평가하지 않는다
|
||||
|
||||
금지:
|
||||
|
||||
- 왜 못 했어요?
|
||||
- 얼마나 산만했나요?
|
||||
- 다시 집중하세요
|
||||
|
||||
허용:
|
||||
|
||||
- 다음 한 조각이 있나요?
|
||||
- 지금 다시 시작할 수 있게 한 줄만 남겨볼까요?
|
||||
- 여기까지로 충분한가요?
|
||||
|
||||
### 3. goal은 1개, microStep도 1개
|
||||
|
||||
Refocus는 절대 리스트 관리 UI가 되면 안 된다.
|
||||
|
||||
- multi-step 금지
|
||||
- queue 금지
|
||||
- subtask list 금지
|
||||
|
||||
### 4. 배경은 항상 주인공이다
|
||||
|
||||
Refocus UI는 강한 모달이 아니라 **배경 위에 잠깐 생겼다가 사라지는 얇은 recovery layer**여야 한다.
|
||||
|
||||
### 5. Premium은 절제에서 온다
|
||||
|
||||
좋은 Refocus는 화려한 카드나 모션이 아니라 아래에서 온다.
|
||||
|
||||
- 올바른 정보 밀도
|
||||
- 1개의 명확한 행동
|
||||
- 자연스러운 등장과 퇴장
|
||||
- 배경과 어우러지는 재질감
|
||||
|
||||
---
|
||||
|
||||
## 6. Refocus가 필요한 순간들
|
||||
|
||||
Refocus는 아래 4개 진입점으로 고정한다.
|
||||
|
||||
### A. Pause 직후
|
||||
|
||||
사용자가 세션을 멈춘 뒤 다시 돌아와야 하는 순간.
|
||||
|
||||
### B. MicroStep 완료 직후
|
||||
|
||||
현재 한 조각은 끝났지만 다음 행동이 아직 흐린 순간.
|
||||
|
||||
### C. 사용자가 의도를 직접 수정하고 싶을 때
|
||||
|
||||
goal이나 microStep을 다시 정리하고 싶은 수동 진입.
|
||||
|
||||
### D. 세션에서 멀어졌다가 복귀했을 때
|
||||
|
||||
탭을 바꾸거나 잠깐 이탈한 후 다시 `/space`로 돌아온 상황.
|
||||
|
||||
---
|
||||
|
||||
## 7. 화면 상태 모델
|
||||
|
||||
Refocus는 아래 5개 상태만 가진다.
|
||||
|
||||
1. `Focused`
|
||||
2. `Paused`
|
||||
3. `Refocus`
|
||||
4. `Next Beat`
|
||||
5. `Complete`
|
||||
|
||||
한 번에 활성화되는 확장 상태는 하나만 허용한다.
|
||||
|
||||
- `Refocus`
|
||||
- `Next Beat`
|
||||
- `Complete`
|
||||
|
||||
이 셋은 동시에 보이면 안 된다.
|
||||
|
||||
---
|
||||
|
||||
## 8. 상태별 상세 UX
|
||||
|
||||
### 8.1 Focused
|
||||
|
||||
목표:
|
||||
|
||||
- 사용자가 UI를 거의 의식하지 않고 일하는 상태
|
||||
|
||||
보여야 하는 것:
|
||||
|
||||
- goal
|
||||
- optional microStep
|
||||
- timer HUD
|
||||
- minimal controls
|
||||
|
||||
보이면 안 되는 것:
|
||||
|
||||
- 질문
|
||||
- 복구 유도 문구
|
||||
- task-like affordance
|
||||
|
||||
### 8.2 Paused
|
||||
|
||||
목표:
|
||||
|
||||
- 세션이 끊긴 느낌이 아니라 잠시 호흡을 고르는 느낌
|
||||
|
||||
행동:
|
||||
|
||||
- 바로 큰 sheet를 띄우지 않는다
|
||||
- HUD 상태가 paused로 바뀌고,
|
||||
- `다시 시작할 준비가 되면 한 조각만 다시 잡는다`는 감각을 준다
|
||||
|
||||
### 8.3 Refocus
|
||||
|
||||
목표:
|
||||
|
||||
- 목표를 버리지 않고 다시 시작점을 만든다
|
||||
|
||||
UI:
|
||||
|
||||
- anchored tray 또는 compact sheet
|
||||
- 필드 2개
|
||||
- 이번 세션 목표
|
||||
- 지금 할 한 조각
|
||||
- CTA 2개
|
||||
- 적용
|
||||
- 취소
|
||||
|
||||
중요:
|
||||
|
||||
- `다시 방향 잡기`는 메인 액션이 아니라 recovery action이어야 한다
|
||||
- button cluster처럼 보이면 안 된다
|
||||
|
||||
### 8.4 Next Beat
|
||||
|
||||
목표:
|
||||
|
||||
- microStep 완료 후 checklist가 아니라 “다음 한 조각”으로 연결
|
||||
|
||||
질문:
|
||||
|
||||
> 다음 한 조각이 있나요?
|
||||
|
||||
행동:
|
||||
|
||||
- `한 조각 정하기`
|
||||
- `없이 계속`
|
||||
|
||||
금지:
|
||||
|
||||
- 다음 step list 펼치기
|
||||
- 여러 개 제안
|
||||
- 정리형 UI
|
||||
|
||||
### 8.5 Complete
|
||||
|
||||
목표:
|
||||
|
||||
- 목표가 끝났을 때 닫음과 다음 시작을 모두 부드럽게 연결
|
||||
|
||||
질문:
|
||||
|
||||
- 여기까지 끝났나요?
|
||||
|
||||
행동:
|
||||
|
||||
- 여기까지 끝내기
|
||||
- 다음 목표로 이어가기
|
||||
- 잠깐 쉬기
|
||||
|
||||
완료는 celebration보다 **closure quality**가 중요하다.
|
||||
|
||||
---
|
||||
|
||||
## 9. 상세 플로우
|
||||
|
||||
### Flow A. Pause -> Refocus -> Resume
|
||||
|
||||
1. 사용자가 pause 한다
|
||||
2. UI는 즉시 평가하지 않는다
|
||||
3. 사용자가 resume을 누르거나 intent를 누르면 Refocus로 들어갈 수 있다
|
||||
4. 사용자는 goal / microStep 중 필요한 것만 조정한다
|
||||
5. `적용 후 이어가기` 또는 `적용` 뒤 resume
|
||||
|
||||
목표:
|
||||
|
||||
- pause 이후 바로 복귀하는 것이 아니라,
|
||||
- 한 번 숨을 고르고 다시 붙잡게 만든다
|
||||
|
||||
### Flow B. MicroStep Complete -> Next Beat
|
||||
|
||||
1. 사용자가 microStep completion mark를 누른다
|
||||
2. 기존 microStep은 조용히 처리된다
|
||||
3. `다음 한 조각이 있나요?`가 나타난다
|
||||
4. 사용자는
|
||||
- 새 microStep을 적거나
|
||||
- 없이 계속 간다
|
||||
|
||||
목표:
|
||||
|
||||
- planner처럼 다음 목록을 만드는 것이 아니라
|
||||
- 지금 하나만 다시 붙잡게 한다
|
||||
|
||||
### Flow C. Manual Refocus
|
||||
|
||||
1. 사용자가 goal 영역을 눌러 의도 수정 진입
|
||||
2. goal / microStep 수정
|
||||
3. 적용
|
||||
4. 조용히 복귀
|
||||
|
||||
목표:
|
||||
|
||||
- 세션을 깨지 않는 범위에서 intent만 조정
|
||||
|
||||
### Flow D. Goal Complete -> Next Goal or Close
|
||||
|
||||
1. 사용자가 goal complete 진입
|
||||
2. 현재 목표를 닫을지, 다음 목표로 이어갈지 선택
|
||||
3. 다음 목표는 여전히 1개만 다룬다
|
||||
|
||||
목표:
|
||||
|
||||
- checklist가 아니라 clean transition
|
||||
|
||||
---
|
||||
|
||||
## 10. UI 구조
|
||||
|
||||
### 레이어 구조
|
||||
|
||||
#### Layer 1. Background
|
||||
|
||||
- 배경 이미지/영상
|
||||
- 주인공
|
||||
|
||||
#### Layer 2. Core HUD
|
||||
|
||||
- timer
|
||||
- sound / scene controls
|
||||
- intent card
|
||||
|
||||
#### Layer 3. Recovery Layer
|
||||
|
||||
- refocus tray
|
||||
- next beat prompt
|
||||
- complete tray
|
||||
|
||||
Recovery Layer는 Core HUD와 같은 material family를 가져야 한다.
|
||||
다만 더 조용하고 얇아야 한다.
|
||||
|
||||
### Material 방향
|
||||
|
||||
- iOS 계열의 refined glass 참고
|
||||
- 강한 탁도보다 `투명 + blur + 얕은 경계`를 우선
|
||||
- glow, heavy shadow, thick border 금지
|
||||
- chip 남발 금지
|
||||
|
||||
### 모션 방향
|
||||
|
||||
- 빠르게 튀어나오지 않는다
|
||||
- appear 220~280ms
|
||||
- disappear 180~220ms
|
||||
- scale보다는 opacity + position shift 위주
|
||||
|
||||
---
|
||||
|
||||
## 11. 카피라이팅 방향
|
||||
|
||||
### tone
|
||||
|
||||
- 짧다
|
||||
- 저압력이다
|
||||
- 평가하지 않는다
|
||||
- 다시 시작할 수 있게 한다
|
||||
|
||||
### 좋은 예
|
||||
|
||||
- `지금 할 한 조각`
|
||||
- `다음 한 조각이 있나요?`
|
||||
- `한 줄만 다듬고 다시 시작해요.`
|
||||
- `여기까지로 충분한가요?`
|
||||
|
||||
### 피해야 할 예
|
||||
|
||||
- `할 일`
|
||||
- `리스트`
|
||||
- `관리`
|
||||
- `다음 단계들`
|
||||
- `왜 못 했나요?`
|
||||
|
||||
---
|
||||
|
||||
## 12. Free / Pro에서의 역할
|
||||
|
||||
### Free
|
||||
|
||||
- 기본 refocus 진입
|
||||
- goal / microStep 재설정
|
||||
- next beat prompt
|
||||
- goal complete 기본 흐름
|
||||
|
||||
### Pro
|
||||
|
||||
- 더 정교한 refocus guidance
|
||||
- 세션 패턴 기반 microStep 제안
|
||||
- 어떤 ritual에서 복귀율이 높은지 review에 반영
|
||||
|
||||
중요:
|
||||
|
||||
Pro는 `더 많은 목표`가 아니라 `더 높은 복귀 성공률`을 파는 쪽으로 가야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 13. 성공 지표
|
||||
|
||||
Refocus System은 아래 지표로 판단한다.
|
||||
|
||||
- Pause 후 Resume 비율
|
||||
- Pause 후 Refocus 진입 비율
|
||||
- Refocus 후 2분 이상 유지 비율
|
||||
- MicroStep 완료 후 Next Beat 입력 비율
|
||||
- Goal complete 후 다음 세션 전환 비율
|
||||
- 세션 중 abandon 비율 감소
|
||||
|
||||
---
|
||||
|
||||
## 14. 구현 우선순위
|
||||
|
||||
### Slice 1
|
||||
|
||||
- pause 이후 Refocus 진입 구조 정리
|
||||
- goal / microStep 수정 tray 정교화
|
||||
- 한 번에 하나의 overlay만 뜨도록 상태 정리
|
||||
|
||||
### Slice 2
|
||||
|
||||
- microStep 완료 -> Next Beat 흐름 정리
|
||||
- checklist 느낌 제거
|
||||
|
||||
### Slice 3
|
||||
|
||||
- goal complete tray의 완성도 향상
|
||||
- closure / next goal / break 분기 정리
|
||||
|
||||
### Slice 4
|
||||
|
||||
- Pro용 adaptive refocus 기획
|
||||
- review와 refocus 연결
|
||||
|
||||
---
|
||||
|
||||
## 15. 절대 피해야 할 방향
|
||||
|
||||
- pause를 실패처럼 느끼게 하는 UX
|
||||
- checklist UI
|
||||
- multi-step planner화
|
||||
- 과한 그래프 / 통계 immediate 노출
|
||||
- 배경보다 더 앞에 나오는 recovery UI
|
||||
- action chip 남발
|
||||
- 한 번에 여러 질문을 던지는 sheet
|
||||
|
||||
---
|
||||
|
||||
## 16. 이 문서를 기준으로 다음 구현에서 꼭 지켜야 할 것
|
||||
|
||||
- Refocus는 `편집 기능`이 아니라 `recovery ritual`처럼 느껴져야 한다
|
||||
- 사용자는 한 번에 하나의 행동만 선택해야 한다
|
||||
- 배경은 절대 희생시키지 않는다
|
||||
- premium quality는 더 많은 glass가 아니라 더 적은 friction에서 나온다
|
||||
441
docs/11_away_return_recovery_spec.md
Normal file
441
docs/11_away_return_recovery_spec.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# 11. Away / Return Recovery Spec
|
||||
|
||||
Last Updated: 2026-03-14
|
||||
|
||||
이 문서는 사용자가 `pause`를 누르지 않고 그냥 자리를 떠난 경우를 VibeRoom이 어떻게 감지하고, 어떻게 맞이하고, 어떻게 다시 복귀시킬지를 정의하는 상세 기준 문서다.
|
||||
|
||||
관련 문서:
|
||||
|
||||
- `../../product_principles.md`
|
||||
- `../../current_context.md`
|
||||
- `10_refocus_system_spec.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 왜 이 기획이 중요한가
|
||||
|
||||
ADHD 성향 사용자와 프리랜서는 자주 아래처럼 이탈한다.
|
||||
|
||||
- `pause`를 누를 생각도 못 한 채 자리에서 일어남
|
||||
- 잠깐 딴 탭을 보다가 세션 흐름을 잃음
|
||||
- focus가 끝났는지도 모른 채 돌아옴
|
||||
|
||||
이 상황을 제품이 다루지 않으면 아래 문제가 생긴다.
|
||||
|
||||
- Refocus가 “사용자가 pause를 눌렀을 때만 작동하는 기능”으로 축소된다
|
||||
- 세션이 실제 흐름보다 더 기계적으로 느껴진다
|
||||
- `pause`와 `break`가 같은 “멈춘 상태”처럼 읽힌다
|
||||
- 돌아온 사용자를 잘못된 상태로 맞이하게 된다
|
||||
|
||||
VibeRoom은 감시 앱이 되어서는 안 되지만,
|
||||
**돌아왔을 때의 복귀 품질**은 반드시 설계해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 한 줄 정의
|
||||
|
||||
> Away / Return은 사용자의 무의식적 이탈을 실패로 다루지 않고, 돌아왔을 때 가장 자연스럽게 다시 몰입 위에 올려놓는 복귀 레이어다.
|
||||
|
||||
중요한 점:
|
||||
|
||||
- 사용자를 통제하거나 막는 기능이 아니다
|
||||
- “왜 떠났는가”를 추궁하지 않는다
|
||||
- 핵심은 detection보다 **return UX**다
|
||||
|
||||
---
|
||||
|
||||
## 3. 이 시스템이 해결해야 하는 문제
|
||||
|
||||
### 문제 A. 사용자는 `pause`를 누르지 않는다
|
||||
|
||||
사용자는 실제로는 쉬고 싶어서 일어난 것이어도,
|
||||
제품 안에서는 아무 액션도 하지 않은 채 세션이 흘러간다.
|
||||
|
||||
### 문제 B. focus가 끝났는데 break처럼 보인다
|
||||
|
||||
사용자가 없던 사이 focus가 끝났다면, 돌아왔을 때 단순 `Break`로 맞이하면 안 된다.
|
||||
그건 제품이 사용자의 실제 맥락을 오해한 것이다.
|
||||
|
||||
### 문제 C. pause와 break가 겹쳐 보인다
|
||||
|
||||
현재 감정적으로는 이렇게 읽히기 쉽다.
|
||||
|
||||
- `Pause`: 내가 멈춤
|
||||
- `Break`: focus가 끝나서 쉬는 중
|
||||
|
||||
둘은 의미가 다른데, UI와 상태가 충분히 분리되지 않으면 같은 “멈춤”처럼 느껴진다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 핵심 원칙
|
||||
|
||||
### 1. 감시는 하지 않는다
|
||||
|
||||
아래는 금지한다.
|
||||
|
||||
- webcam / face tracking
|
||||
- 키보드/마우스 무입력만으로 강제 이탈 판정
|
||||
- 사용자를 혼내는 알림
|
||||
- “집중하지 않았네요” 류의 카피
|
||||
|
||||
### 2. 확실한 신호만 사용한다
|
||||
|
||||
웹에서 “자리 비움”은 정확히 알 수 없다.
|
||||
따라서 v1은 아래처럼 비교적 강한 신호만 쓴다.
|
||||
|
||||
- `visibilitychange`
|
||||
- `pagehide`
|
||||
- 브라우저/기기 sleep 이후 큰 시간 점프
|
||||
- 창 복귀 시점
|
||||
|
||||
### 3. detection보다 return이 중요하다
|
||||
|
||||
중요한 것은 “네가 떠났다는 걸 알아냈다”가 아니라,
|
||||
**“돌아온 지금 무엇을 제안할 것인가”**다.
|
||||
|
||||
### 4. Pause와 Break는 명확히 다르게 느껴져야 한다
|
||||
|
||||
- `Pause`는 recovery tone
|
||||
- `Break`는 release / reset tone
|
||||
- `Return`은 re-entry tone
|
||||
|
||||
### 5. focus ended while away는 Break가 아니라 Return이다
|
||||
|
||||
사용자가 없는 동안 focus가 끝났다면,
|
||||
돌아온 순간의 경험은 `쉬는 중`이 아니라 `복귀 결정`이어야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 상태 모델
|
||||
|
||||
VibeRoom의 세션 상태를 제품적으로는 아래처럼 다룬다.
|
||||
|
||||
### Core States
|
||||
|
||||
- `Focus`
|
||||
- `Pause`
|
||||
- `Break`
|
||||
|
||||
### Recovery States
|
||||
|
||||
- `AwayCandidate`
|
||||
- `Return`
|
||||
|
||||
### 의미
|
||||
|
||||
#### Focus
|
||||
|
||||
사용자가 현재 세션 안에서 일하고 있음
|
||||
|
||||
#### Pause
|
||||
|
||||
사용자가 의도적으로 멈춤
|
||||
|
||||
#### Break
|
||||
|
||||
focus 블록을 끝내고 의도적으로 쉬는 상태
|
||||
|
||||
#### AwayCandidate
|
||||
|
||||
사용자가 실제로 자리를 떴을 가능성이 높은 내부 판단 상태
|
||||
|
||||
#### Return
|
||||
|
||||
이탈 후 다시 돌아온 순간, 제품이 복귀를 제안하는 상태
|
||||
|
||||
---
|
||||
|
||||
## 6. 웹에서의 감지 전략
|
||||
|
||||
### v1에서 사용하는 신호
|
||||
|
||||
#### 1. visibility hidden
|
||||
|
||||
- 사용자가 탭을 떠남
|
||||
- 브라우저가 background로 감
|
||||
|
||||
#### 2. pagehide / tab background
|
||||
|
||||
- 페이지가 전환되거나 숨겨짐
|
||||
|
||||
#### 3. sleep / wake 시간 점프
|
||||
|
||||
- 사용자가 기기를 잠그거나 화면이 꺼졌다가 돌아왔을 가능성
|
||||
- `Date.now()` 기준 큰 delta로 감지
|
||||
|
||||
### v1에서 사용하지 않는 신호
|
||||
|
||||
#### blur / focus 단독
|
||||
|
||||
너무 오탐이 많다.
|
||||
|
||||
#### 무입력 시간만으로 Away 판단
|
||||
|
||||
읽고 생각하는 사용자를 잘못 판정할 수 있다.
|
||||
|
||||
#### pointer / keyboard tracking 강제화
|
||||
|
||||
감시처럼 느껴질 수 있어 금지
|
||||
|
||||
---
|
||||
|
||||
## 7. 판단 규칙
|
||||
|
||||
### Rule 1. focus 중 hidden/wake gap 발생
|
||||
|
||||
상태:
|
||||
|
||||
- `Focus` 중
|
||||
- `visibility hidden` 또는 `sleep/wake delta` 발생
|
||||
|
||||
처리:
|
||||
|
||||
- 내부적으로 `AwayCandidate`
|
||||
|
||||
### Rule 2. 돌아왔을 때 focus가 아직 안 끝남
|
||||
|
||||
상태:
|
||||
|
||||
- 사용자가 복귀
|
||||
- 남은 focus 시간이 남아 있음
|
||||
|
||||
처리:
|
||||
|
||||
- `Return` tray 노출
|
||||
- 질문:
|
||||
- `이어서 할까요?`
|
||||
- `한 조각 다시 잡을까요?`
|
||||
|
||||
### Rule 3. 돌아왔을 때 focus가 이미 끝남
|
||||
|
||||
상태:
|
||||
|
||||
- 사용자가 복귀
|
||||
- focus phase는 끝났음
|
||||
- 하지만 사용자는 그 종료를 경험하지 못했음
|
||||
|
||||
처리:
|
||||
|
||||
- `Break` 직접 진입 금지
|
||||
- `Return` tray 노출
|
||||
- 질문:
|
||||
- `자리를 비운 사이 이 블록이 끝났어요. 지금 어떻게 이어갈까요?`
|
||||
|
||||
행동:
|
||||
|
||||
- `지금부터 쉬기`
|
||||
- `다음 목표로 이어가기`
|
||||
- `한 조각 다시 잡기`
|
||||
|
||||
### Rule 4. pause 상태에서 복귀
|
||||
|
||||
상태:
|
||||
|
||||
- 이미 사용자가 직접 `Pause`한 상태
|
||||
|
||||
처리:
|
||||
|
||||
- 이건 Away보다 Pause 흐름이 우선
|
||||
- 기존 pause recovery prompt 유지
|
||||
|
||||
즉, `Away / Return`은 manual pause를 덮어쓰지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 8. Return UX 설계
|
||||
|
||||
### Return의 목적
|
||||
|
||||
- 죄책감 없이 다시 올려놓기
|
||||
- `지금 어떤 상태인지`를 간단히 알려주기
|
||||
- 선택지를 2~3개만 제시하기
|
||||
|
||||
### Return의 기본 구조
|
||||
|
||||
- eyebrow: `다시 돌아왔어요`
|
||||
- 짧은 현재 맥락 설명
|
||||
- primary action 1개
|
||||
- secondary action 1개
|
||||
- tertiary는 최대 1개
|
||||
|
||||
### Case A. focus still running
|
||||
|
||||
카피 예:
|
||||
|
||||
- 제목: `이어서 갈까요?`
|
||||
- 설명: `흐름은 그대로 남아 있어요. 바로 이어가거나 한 조각만 다시 잡을 수 있어요.`
|
||||
|
||||
행동:
|
||||
|
||||
- primary: `이어서 하기`
|
||||
- secondary: `한 조각 다시 잡기`
|
||||
|
||||
### Case B. focus ended while away
|
||||
|
||||
카피 예:
|
||||
|
||||
- 제목: `자리를 비운 사이 이 블록이 끝났어요.`
|
||||
- 설명: `지금부터 쉬거나, 다음으로 이어갈 수 있어요.`
|
||||
|
||||
행동:
|
||||
|
||||
- primary: `지금부터 쉬기`
|
||||
- secondary: `다음 목표 이어가기`
|
||||
- tertiary: `한 조각 다시 잡기`
|
||||
|
||||
### Case C. break ended while away
|
||||
|
||||
이건 v1에서는 단순화한다.
|
||||
|
||||
- break가 끝났고 사용자가 돌아왔다면
|
||||
- `다음 블록으로 이어갈까요?` 정도의 단순 return prompt만 둔다
|
||||
- 이 상태는 후속 slice에서 다룬다
|
||||
|
||||
---
|
||||
|
||||
## 9. Pause / Break / Return의 차이
|
||||
|
||||
### Pause
|
||||
|
||||
- 사용자가 직접 멈춘 상태
|
||||
- 톤: recovery
|
||||
- 질문:
|
||||
- `한 조각 다시 잡기`
|
||||
- `이대로 이어가기`
|
||||
|
||||
### Break
|
||||
|
||||
- 사용자가 focus 블록을 끝낸 뒤 쉬는 상태
|
||||
- 톤: release / reset
|
||||
- 질문:
|
||||
- `쉬기 계속`
|
||||
- `다음으로 가기`
|
||||
|
||||
### Return
|
||||
|
||||
- 사용자가 떠났다가 돌아온 상태
|
||||
- 톤: re-entry
|
||||
- 질문:
|
||||
- `지금 어디서 다시 시작할까?`
|
||||
|
||||
핵심:
|
||||
|
||||
`Pause`와 `Return`은 비슷하지만 다르다.
|
||||
|
||||
- Pause는 사용자가 멈춘 것
|
||||
- Return은 제품이 사용자의 이탈을 감지하고 다시 맞이하는 것
|
||||
|
||||
---
|
||||
|
||||
## 10. UI / Layer 구조
|
||||
|
||||
### Layer 원칙
|
||||
|
||||
- Away / Return도 기존 recovery family 안에 들어간다
|
||||
- 한 번에 하나의 recovery layer만 열 수 있다
|
||||
|
||||
우선순위:
|
||||
|
||||
1. `Complete`
|
||||
2. `Return`
|
||||
3. `Pause`
|
||||
4. `Refocus`
|
||||
5. `Next Beat`
|
||||
|
||||
### 이유
|
||||
|
||||
- Complete는 가장 명시적이고 종료 의미가 강함
|
||||
- Return은 환경 변화에 대한 복귀 진입점
|
||||
- Pause는 사용자의 수동 멈춤
|
||||
- Refocus와 Next Beat는 더 세부적인 recovery 단계
|
||||
|
||||
---
|
||||
|
||||
## 11. 카피 원칙
|
||||
|
||||
### 좋은 톤
|
||||
|
||||
- `다시 돌아왔어요`
|
||||
- `흐름은 그대로 남아 있어요`
|
||||
- `한 조각만 다시 잡을까요?`
|
||||
- `지금부터 쉬거나, 다음으로 이어갈 수 있어요`
|
||||
|
||||
### 금지 톤
|
||||
|
||||
- `집중을 놓쳤네요`
|
||||
- `왜 자리를 비우셨나요`
|
||||
- `세션이 중단되었습니다`
|
||||
- `복귀하세요`
|
||||
|
||||
---
|
||||
|
||||
## 12. 구현 우선순위
|
||||
|
||||
### Slice A. Spec 정리
|
||||
|
||||
- 상태 모델 확정
|
||||
- detection 전략 확정
|
||||
- Return UX 문구 확정
|
||||
|
||||
### Slice B. Detection 도입
|
||||
|
||||
- `visibilitychange`
|
||||
- `pagehide`
|
||||
- sleep/wake delta
|
||||
|
||||
### Slice C. Return Tray 구현
|
||||
|
||||
- focus still running case
|
||||
- focus ended while away case
|
||||
|
||||
### Slice D. Pause / Break 분리 강화
|
||||
|
||||
- copy
|
||||
- visual tone
|
||||
- action hierarchy
|
||||
|
||||
---
|
||||
|
||||
## 13. 측정 지표
|
||||
|
||||
- hidden -> return 이후 resume 비율
|
||||
- away detected 후 abandon 비율
|
||||
- focus ended while away 후 다음 행동 선택률
|
||||
- return tray에서 primary 선택 비율
|
||||
- return 후 2분 이상 유지 비율
|
||||
|
||||
---
|
||||
|
||||
## 14. 이번 기획이 기존 계획과 어떻게 연결되는가
|
||||
|
||||
이 문서는 `중간에 끼어든 기획`이 맞지만, 방향을 흐리는 끼어듦이 아니다.
|
||||
오히려 `Refocus System`을 완성하기 위해 필요한 핵심 보강이다.
|
||||
|
||||
따라서 순서는 이렇게 재정렬한다.
|
||||
|
||||
1. `Refocus System` 구현
|
||||
2. `Away / Return Recovery` 구현
|
||||
3. 그 다음에 originally planned visual polish
|
||||
4. 그 다음에 Break 품질과 Review 확장
|
||||
|
||||
즉, 원래 예정했던 다음 기획은 사라지지 않는다.
|
||||
**Away / Return이 먼저 들어오고, visual polish와 break refinement가 그 뒤로 밀리는 것**이다.
|
||||
|
||||
---
|
||||
|
||||
## 15. 절대 피해야 할 방향
|
||||
|
||||
- 감시처럼 느껴지는 detection
|
||||
- 무입력 시간을 벌점처럼 해석하는 것
|
||||
- hidden -> 자동 pause 강제
|
||||
- 돌아왔을 때 죄책감 유발 카피
|
||||
- focus ended while away인데 그냥 break로 보내는 것
|
||||
|
||||
---
|
||||
|
||||
## 16. 다음 구현에서 꼭 지켜야 할 것
|
||||
|
||||
- detection은 조심스럽게, return UX는 분명하게
|
||||
- pause와 return을 섞지 말 것
|
||||
- break는 reward / reset tone으로 유지할 것
|
||||
- away는 “실패”가 아니라 “복귀가 필요한 순간”으로 다룰 것
|
||||
285
docs/12_core_loop_execution_roadmap.md
Normal file
285
docs/12_core_loop_execution_roadmap.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# 12. Core Loop Execution Roadmap
|
||||
|
||||
Last Updated: 2026-03-14
|
||||
|
||||
이 문서는 VibeRoom의 핵심 제품 기획을 **어떤 순서로 구현까지 연결할지**를 정의하는 실행 로드맵이다.
|
||||
|
||||
중요한 목적은 두 가지다.
|
||||
|
||||
- 중간에 끼어드는 기획이 전체 방향을 흔들지 않게 한다
|
||||
- 어떤 세션, 어떤 에이전트가 들어와도 **다음으로 무엇을 해야 하는지** 즉시 이해하게 한다
|
||||
|
||||
관련 문서:
|
||||
|
||||
- `../../product_principles.md`
|
||||
- `../../current_context.md`
|
||||
- `09_app_entry_detailed_spec.md`
|
||||
- `10_refocus_system_spec.md`
|
||||
- `11_away_return_recovery_spec.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 기본 원칙
|
||||
|
||||
VibeRoom은 아래 방식으로 진행한다.
|
||||
|
||||
1. 바뀌면 안 되는 제품 원칙을 먼저 고정한다
|
||||
2. 코어 루프를 정의한다
|
||||
3. 코어 루프를 **slice 단위**로 상세 기획한다
|
||||
4. 각 slice는 `기획 -> 구현 -> 브라우저 QA -> 문서 업데이트`까지 닫고 간다
|
||||
5. 다음 slice는 이전 slice가 제품적으로 안정된 뒤에 들어간다
|
||||
|
||||
즉:
|
||||
|
||||
- `모든 기획을 끝낸 뒤 한 번에 개발`하지 않는다
|
||||
- `생각나는 기능을 바로 구현`하지도 않는다
|
||||
- **큰 방향은 먼저 고정하고, 세부는 vertical slice로 기획 후 바로 구현**한다
|
||||
|
||||
---
|
||||
|
||||
## 2. 현재까지 완료된 것
|
||||
|
||||
### 완료 1. Product Principles
|
||||
|
||||
- 루트 문서 `../../product_principles.md`
|
||||
- single-goal, premium focus, anti-to-do 방향 고정
|
||||
|
||||
### 완료 2. `/app` Entry Reframe
|
||||
|
||||
- 문서: `09_app_entry_detailed_spec.md`
|
||||
- 구현 상태:
|
||||
- `/app`은 single-goal commitment gate
|
||||
- planner/list-first 구조 제거
|
||||
- current session이 있으면 resume 우선
|
||||
|
||||
### 완료 3. Refocus System 기본 구조
|
||||
|
||||
- 문서: `10_refocus_system_spec.md`
|
||||
- 구현 상태:
|
||||
- pause -> refocus 흐름의 기본 skeleton 존재
|
||||
- next beat / goal complete의 상태 분리 시작
|
||||
|
||||
---
|
||||
|
||||
## 3. 지금 끼어든 기획의 위치
|
||||
|
||||
### 끼어든 기획
|
||||
|
||||
- `11_away_return_recovery_spec.md`
|
||||
|
||||
이 기획은 원래 흐름을 덮어쓴 것이 아니다.
|
||||
이 문서는 **Refocus System을 실제 사용자 행동에 맞게 완성하기 위해 중간에 추가된 필수 slice**다.
|
||||
|
||||
왜 끼어들었는가:
|
||||
|
||||
- 사용자는 `pause`를 누르지 않고 자리를 뜨는 경우가 많다
|
||||
- 이 케이스를 다루지 않으면 Refocus System이 반쪽짜리가 된다
|
||||
- `pause`와 `break`가 겹쳐 보이는 문제도 여기서 정리해야 한다
|
||||
|
||||
즉, 이 기획은 “옆길”이 아니라 **Refocus System의 확장 파트**다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 현재 기준의 구현 순서
|
||||
|
||||
아래 순서를 공식 순서로 고정한다.
|
||||
|
||||
### Phase 1. `/app` Entry Polish
|
||||
|
||||
목적:
|
||||
|
||||
- 시작 마찰을 최소화
|
||||
- `/app`이 planner가 아니라 commitment gate로 읽히게 고정
|
||||
|
||||
상태:
|
||||
|
||||
- 기획 완료
|
||||
- 구현 대부분 완료
|
||||
- 남은 것은 브라우저 QA와 polish
|
||||
|
||||
### Phase 2. Refocus System
|
||||
|
||||
문서:
|
||||
|
||||
- `10_refocus_system_spec.md`
|
||||
|
||||
목적:
|
||||
|
||||
- pause 이후 복귀
|
||||
- microStep 완료 후 다음 한 조각 연결
|
||||
- goal complete의 자연스러운 closure
|
||||
|
||||
상태:
|
||||
|
||||
- 기획 완료
|
||||
- 구현 진행 중
|
||||
|
||||
### Phase 3. Away / Return Recovery
|
||||
|
||||
문서:
|
||||
|
||||
- `11_away_return_recovery_spec.md`
|
||||
|
||||
목적:
|
||||
|
||||
- 사용자가 `pause` 없이 떠난 경우를 감지
|
||||
- 돌아왔을 때 Break가 아니라 Return UX로 맞이
|
||||
- `pause`, `break`, `return`의 감정적 의미를 분리
|
||||
|
||||
상태:
|
||||
|
||||
- 기획 완료
|
||||
- 아직 구현 전
|
||||
|
||||
중요:
|
||||
|
||||
- 이것은 `Refocus System` 다음으로 이어지는 것이 아니라
|
||||
- **Refocus System 구현의 후반부**로 바로 이어진다
|
||||
|
||||
### Phase 4. Complete / Break Separation Polish
|
||||
|
||||
목적:
|
||||
|
||||
- `goal complete`, `break`, `return`의 의미와 재질감 분리
|
||||
- “쉬는 중”과 “복귀 결정”이 섞이지 않게 정리
|
||||
|
||||
상태:
|
||||
|
||||
- 아직 상세 기획 일부만 존재
|
||||
- Away / Return 구현 후 들어간다
|
||||
|
||||
### Phase 5. Weekly Review Reframe
|
||||
|
||||
목적:
|
||||
|
||||
- total time보다 시작 성공률, 복귀율, 유지 패턴 중심으로 재설계
|
||||
|
||||
상태:
|
||||
|
||||
- 아직 본격 기획 전
|
||||
|
||||
### Phase 6. Premium Ambience System
|
||||
|
||||
목적:
|
||||
|
||||
- Portal급 감각 품질 확보
|
||||
- 배경, 사운드, 전환, material의 art direction 통일
|
||||
|
||||
상태:
|
||||
|
||||
- 방향만 존재
|
||||
- review 전후 어느 시점에 들어갈지 조정 가능
|
||||
|
||||
---
|
||||
|
||||
## 5. “그 다음에 하려던 기획”은 무엇인가
|
||||
|
||||
Away / Return이 끼어들기 전, 다음으로 예정된 축은 아래 두 가지였다.
|
||||
|
||||
1. `Refocus / Next Beat / Goal Complete`의 premium polish
|
||||
2. `Pause`와 `Break`의 감정/구조 분리
|
||||
|
||||
즉, 원래 예정된 기획은 사라진 것이 아니다.
|
||||
단지 순서가 아래처럼 정리된 것이다.
|
||||
|
||||
이전 예상 순서:
|
||||
|
||||
1. Refocus polish
|
||||
2. Break refinement
|
||||
3. Review
|
||||
|
||||
현재 확정 순서:
|
||||
|
||||
1. Refocus polish
|
||||
2. Away / Return Recovery
|
||||
3. Break refinement
|
||||
4. Review
|
||||
|
||||
정리하면:
|
||||
|
||||
- **Away / Return은 중간에 끼어든 임시 아이디어가 아니라**
|
||||
- `Break refinement`보다 먼저 처리해야 하는 선행 조건이다
|
||||
|
||||
---
|
||||
|
||||
## 6. 다음 세션에서의 실제 작업 순서
|
||||
|
||||
다음 작업은 아래 순서로 진행한다.
|
||||
|
||||
### Step 1. `/space` Refocus visual/behavior polish 마무리
|
||||
|
||||
포함:
|
||||
|
||||
- pause tray
|
||||
- refocus tray
|
||||
- next beat tray
|
||||
- goal complete tray
|
||||
|
||||
완료 기준:
|
||||
|
||||
- recovery flow가 checklist나 planner처럼 보이지 않는다
|
||||
- overlay는 한 번에 하나만 보인다
|
||||
|
||||
### Step 2. Away / Return 구현
|
||||
|
||||
포함:
|
||||
|
||||
- `visibilitychange`
|
||||
- `pagehide`
|
||||
- sleep/wake delta 감지
|
||||
- `Return` tray 추가
|
||||
|
||||
완료 기준:
|
||||
|
||||
- focus ended while away 시 standard break로 바로 가지 않는다
|
||||
- return 상태에서 `지금부터 쉬기 / 이어가기 / 한 조각 다시 잡기`가 가능하다
|
||||
|
||||
### Step 3. Break 분리
|
||||
|
||||
포함:
|
||||
|
||||
- `Pause`, `Break`, `Return` 카피와 material 분리
|
||||
- break를 recovery tone이 아니라 release tone으로 재설계
|
||||
|
||||
### Step 4. Review 상세 기획
|
||||
|
||||
포함:
|
||||
|
||||
- started
|
||||
- resumed
|
||||
- completed
|
||||
- recovery rate
|
||||
- ritual success correlation
|
||||
|
||||
---
|
||||
|
||||
## 7. 의사결정 규칙
|
||||
|
||||
새 기능이나 새 기획이 들어올 때는 아래 규칙으로 판단한다.
|
||||
|
||||
### 바로 진행해도 되는 경우
|
||||
|
||||
- 기존 코어 루프의 빈칸을 메우는 경우
|
||||
- `start / immerse / refocus / return / complete` 중 하나를 선명하게 하는 경우
|
||||
|
||||
### 뒤로 미뤄야 하는 경우
|
||||
|
||||
- list/planner/task manager 느낌을 강하게 만드는 경우
|
||||
- social, buddy, coworking 쪽으로 제품 무게를 옮기는 경우
|
||||
- 화면에 오브젝트와 설정을 더 많이 추가하는 경우
|
||||
|
||||
---
|
||||
|
||||
## 8. 한 줄 결론
|
||||
|
||||
현재 끼어든 `Away / Return` 기획은 옆길이 아니다.
|
||||
|
||||
> 이것은 `Refocus System`을 실제 사용자 행동에 맞게 완성하기 위해 반드시 먼저 처리해야 하는 다음 단계다.
|
||||
|
||||
즉, 다음에 원래 하려던 기획은 사라지지 않았고,
|
||||
순서는 아래처럼 재정렬되었다.
|
||||
|
||||
1. Refocus polish
|
||||
2. Away / Return Recovery
|
||||
3. Break refinement
|
||||
4. Weekly Review
|
||||
@@ -1,18 +1,53 @@
|
||||
# 90. Current State
|
||||
|
||||
Last Updated: 2026-03-12
|
||||
Last Updated: 2026-03-14
|
||||
|
||||
## DONE
|
||||
|
||||
- `/app` single-goal commitment gate 재구성:
|
||||
- 2-step `goal -> ritual` flow 제거
|
||||
- current session이 있으면 `Resume` UI를 우선 노출하고, `/space`로 바로 이어가기만 제안
|
||||
- current session이 없으면 `goal 1개 + optional microStep 1개 + primary CTA`만 남긴 direct start 구조로 단순화
|
||||
- `환경 세팅`, `블록 정리`, scene/sound/timer 선택을 메인 진입 경로에서 제거
|
||||
- suggestion chip은 planner가 아니라 입력 마찰을 줄이는 용도로만 유지
|
||||
- 시작 시에는 기본 ritual(`forest · 50/10 · forest-birds`)로 세션을 열고, 세부 조정은 `/space`에서 하도록 역할을 분리
|
||||
- `/app`이 planner home이 아니라 commitment gate라는 역할을 문서와 구현 모두에서 다시 고정
|
||||
- `/space` Refocus System slice 1 구현:
|
||||
- HUD recovery layer를 `paused / refocus / next-beat / complete` 단일 overlay 상태로 정리
|
||||
- pause 직후 바로 편집 시트를 열지 않고, 작은 recovery prompt를 먼저 노출
|
||||
- `한 조각 다시 잡기`로 refocus에 들어가고, paused 상태에서는 `적용하고 이어가기`로 바로 resume 연결
|
||||
- microStep 완료 후 `다음 한 조각이 있나요?` next-beat prompt로만 이어지게 정리
|
||||
- 한 번에 하나의 recovery tray만 열리도록 hierarchy를 고정
|
||||
- `/space` Refocus System slice 2 구현:
|
||||
- pause prompt의 `이대로 이어가기`가 실제 resume 동작으로 연결
|
||||
- goal complete tray에 `여기까지 끝내기` 경로 추가
|
||||
- 현재 세션을 다음 목표 입력 없이도 정상 완료 처리할 수 있게 연결
|
||||
- goal complete / rest / next-goal의 세 분기가 UI와 동작 모두에서 분리됨
|
||||
- `/space` Refocus System slice 3 구현:
|
||||
- goal complete tray가 초기부터 input form을 강요하지 않도록 progressive disclosure 구조로 변경
|
||||
- `여기까지 끝내기 / 잠깐 쉬기 / 다음 목표 이어가기`를 먼저 제안하고, 다음 목표 입력은 선택 시에만 펼쳐지게 정리
|
||||
- next-beat prompt에 현재 goal 문맥을 함께 보여주도록 보강
|
||||
- `/space` Refocus System slice 4 구현:
|
||||
- pause / next-beat / complete / refocus tray의 glass material, hairline, spacing을 공통 규칙으로 정리
|
||||
- 선택 액션을 단순 inline 링크에서 quiet option row로 바꿔 recovery decision hierarchy를 선명하게 만듦
|
||||
- `goal complete`의 세 분기를 같은 tray 안의 선택 행으로 통합해 planner/form 느낌을 약화
|
||||
- `refocus` form field와 footer action 톤을 다른 recovery tray와 같은 제품군으로 맞춤
|
||||
- `/space` Away / Return Recovery slice 구현:
|
||||
- `visibilitychange`, `pagehide`, sleep/wake gap 기반 AwayCandidate 감지 추가
|
||||
- 짧은 탭 전환에는 반응하지 않도록 hidden threshold를 둠
|
||||
- 돌아왔을 때 focus가 아직 running이면 `Return` tray에서 `이어서 하기 / 한 조각 다시 잡기`를 제안
|
||||
- 자리를 비운 사이 focus가 끝나 break phase가 되었으면 standard break 대신 `Return` tray를 먼저 띄움
|
||||
- 이 경우 `지금부터 쉬기 / 다음 목표 이어가기 / 한 조각 다시 잡기`를 선택할 수 있음
|
||||
- `다음 목표 이어가기`는 `Goal Complete` next view로 바로 연결됨
|
||||
|
||||
- Focus Entry Surface / Execution Surface 재정의:
|
||||
- `/app`을 planning home이 아니라 hero-first focus entry surface로 재구성
|
||||
- 상단 카피를 `Planning Home` 톤에서 `지금 시작할 첫 블록` 진입 톤으로 교체
|
||||
- 메인 hero에 one-line goal input + 단일 primary CTA `지금 시작`만 남기고, 첫 진입의 주 행동을 고정
|
||||
- empty state에서는 starter draft를 자동 주입하지 않고 placeholder 입력으로 시작한다
|
||||
- suggestion chip으로 draft를 빠르게 교체할 수 있게 하고, 직접 타이핑 시에는 ad-hoc start를 허용
|
||||
- block CRUD는 메인 화면에서 제거하고 `블록 정리` manage sheet 안으로 내렸다
|
||||
- `FocusPlan` current item은 hero prefill로 이어지고, preview row는 최대 2개까지만 보조적으로 노출
|
||||
- Free는 1개, Pro는 최대 5개 블록까지 관리하도록 프론트 제한을 유지
|
||||
- 이후 `/app`은 current session이 있으면 `Resume`, 없으면 single-goal direct start만 남기는 commitment gate로 더 줄였다
|
||||
- block CRUD, preview row, list-first 구조는 메인 진입 경로에서 제거했다
|
||||
- `/space`는 planning overview 없이 goal/scene/sound/timer + HUD 실행 화면으로 정리
|
||||
- focus-plan / focus-session 서버 계약 연결:
|
||||
- `GET /api/v1/focus-plan/today`
|
||||
@@ -36,8 +71,8 @@ Last Updated: 2026-03-12
|
||||
- `/app` route를 Session OS focus entry surface로 복구:
|
||||
- `/app` route가 `/space` redirect 대신 `FocusDashboardWidget`을 렌더링
|
||||
- current/next summary card와 list-first 구조를 제거하고, entry hero가 above-the-fold를 차지한다
|
||||
- `start`를 `plan persistence`와 분리해 goal only 쿼리로도 `/space` 진입 가능하게 정리했다
|
||||
- Free에서 두 번째 블록 추가 시도 시 manage sheet 내부에서 paywall로 진입
|
||||
- 현재는 current session이 있으면 resume, 없으면 `goal + microStep + start`만 제안하는 single-goal entry로 정리됐다
|
||||
- scene/sound/timer는 기본 ritual로 시작하고, 세부 조정은 `/space`에서 담당한다
|
||||
- 플랜 tier 공유 store 추가:
|
||||
- `entities/plan/model/usePlanTier.ts` 추가
|
||||
- localStorage 기반 Free/Pro 상태를 `/app`, `/space`, `/stats`, dock paywall에서 공통 사용
|
||||
@@ -226,19 +261,17 @@ Last Updated: 2026-03-12
|
||||
|
||||
## NEXT
|
||||
|
||||
1. `/app` focus entry surface start/manage 브라우저 스모크
|
||||
2. `/space` goal-complete -> next goal immediate start 흐름 QA
|
||||
1. `/app` single-goal commitment gate 브라우저 스모크
|
||||
2. `/space` intent HUD / refocus / goal-complete hierarchy QA
|
||||
3. `/stats` factual summary / trend / refresh 브라우저 QA
|
||||
|
||||
## RISKS
|
||||
|
||||
- `/app` manage sheet의 리스트는 append-only라 drag/drop reorder는 아직 없다
|
||||
- Free/Pro 제한은 클라이언트 local tier 기준이므로 서버에서 직접 막지 않는다
|
||||
- Free/Pro gating은 localStorage mock tier 기반이라 실제 구독 상태와 연결되지 않았다
|
||||
- `advance-goal`은 atomic endpoint 기준으로 동작하지만, 네트워크 실패 시 사용자는 현재 시트에서 재시도해야 한다
|
||||
- Session OS 도메인은 mock 기반이므로 실제 저장/복구 API 없이도 화면만 먼저 완성된 상태다
|
||||
- empty state에서 CTA는 살아 있지만 실제 시작 전에 입력 포커스가 먼저 필요하므로, 첫 진입 사용성은 브라우저 확인이 필요하다
|
||||
- current item이 아닌 preview row 선택은 ad-hoc start로 처리되므로, 큐 재정렬을 기대하는 사용자와 정신 모델 차이가 생길 수 있다
|
||||
- `/space` paywall 전환 진입점은 `/app` / `/stats` 중심이라 execution 화면만 본 사용자에게는 업그레이드 맥락이 약할 수 있다
|
||||
- `/admin` 업로드 콘솔은 구조 복구가 끝났지만, 실제 파일 업로드 경로는 브라우저 수동 검증 전까지 확정할 수 없다
|
||||
- stage background overscan으로 좁은 화면에서 배경 crop이 조금 더 강하게 느껴질 수 있어 실기기 확인이 필요하다
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Session Brief
|
||||
|
||||
Last Updated: 2026-03-12
|
||||
Last Updated: 2026-03-14
|
||||
|
||||
세션 시작 시 항상 읽는 초소형 스냅샷 문서.
|
||||
|
||||
@@ -14,19 +14,42 @@ Last Updated: 2026-03-12
|
||||
|
||||
## 현재 우선순위
|
||||
|
||||
1. `/app` focus entry surface start/manage 브라우저 QA
|
||||
2. `/space` goal-complete -> next goal immediate start 흐름 QA
|
||||
3. `/stats` factual summary / trend / refresh QA
|
||||
1. `Pause / Break / Return` 분리 polish
|
||||
2. `/space` Refocus + Return 브라우저 QA
|
||||
3. `Weekly Review` 상세 기획
|
||||
|
||||
## 최근 세션 상태
|
||||
|
||||
- `/app`을 planning home이 아니라 focus entry surface로 다시 재구성했다.
|
||||
- hero에 one-line goal input과 단일 CTA `지금 시작`을 두고, 첫 블록 진입을 화면의 주 행동으로 올렸다.
|
||||
- empty state에서는 값을 미리 채우지 않고 placeholder만 두며, 입력 후 바로 `/space`로 들어간다.
|
||||
- suggestion chip으로 draft를 빠르게 바꿀 수 있고, 직접 수정하면 ad-hoc start가 가능하다.
|
||||
- plan CRUD는 메인 화면에서 제거하고 `블록 정리` manage sheet 안으로 내렸다.
|
||||
- current item은 hero를 prefill하고, next item은 최대 2개까지만 얕은 preview로 남긴다.
|
||||
- Free는 1개, Pro는 최대 5개까지 관리한다.
|
||||
- `/space` Refocus System 첫 slice를 구현했다.
|
||||
- pause 직후 바로 편집 시트가 아니라 작은 recovery prompt를 먼저 띄운다.
|
||||
- 여기서 `한 조각 다시 잡기`를 누르면 refocus tray로 들어간다.
|
||||
- paused 상태의 refocus는 `적용하고 이어가기`로 바로 resume까지 연결된다.
|
||||
- microStep 완료 후에는 checklist가 아니라 `다음 한 조각이 있나요?` prompt로만 이어진다.
|
||||
- recovery UI는 `paused / refocus / next-beat / complete` 중 하나만 열리도록 단일 overlay 상태로 묶였다.
|
||||
- `/space` goal complete 종료 경로를 복구했다.
|
||||
- `여기까지 끝내기`로 현재 목표를 다음 목표 입력 없이도 정상 완료 처리할 수 있다.
|
||||
- pause prompt의 `이대로 이어가기`는 단순 닫기가 아니라 실제 resume으로 연결된다.
|
||||
- `/space` goal complete / next beat를 덜 form스럽게 정리했다.
|
||||
- goal complete는 처음부터 input을 요구하지 않고, 선택지를 먼저 보여준 뒤 `다음 목표 이어가기`를 선택했을 때만 입력이 열린다.
|
||||
- next-beat prompt는 현재 goal 문맥을 함께 보여줘서 사용자가 어떤 목표를 이어가는지 잃지 않게 했다.
|
||||
- `/space` recovery tray material과 선택 위계를 같은 패밀리로 맞추기 시작했다.
|
||||
- pause / next-beat / complete tray가 공통 dark-glass shell을 공유한다.
|
||||
- inline 링크 중심이던 선택지를 quiet option row 구조로 바꿔, checklist보다 recovery decision처럼 읽히게 정리했다.
|
||||
- `Goal Complete`는 `여기까지 끝내기 / 잠깐 쉬기 / 다음 목표 이어가기`를 같은 tray 안의 선택 행으로 제시한다.
|
||||
- `Refocus`는 같은 shell 안에서 field / action 톤을 통일해 다른 tray와 같은 제품군처럼 보이게 맞추는 중이다.
|
||||
- `/space` Away / Return Recovery를 구현했다.
|
||||
- `visibilitychange`, `pagehide`, sleep/wake gap 기반 감지를 추가했다.
|
||||
- 짧은 탭 전환에는 반응하지 않도록 hidden threshold를 둬 오탐을 줄였다.
|
||||
- 돌아왔을 때 focus가 계속 running이면 `Return` tray가 `이어서 하기 / 한 조각 다시 잡기`를 제안한다.
|
||||
- 자리를 비운 사이 focus가 끝나 break phase가 되었으면 standard break 대신 `Return` tray가 먼저 뜬다.
|
||||
- 이 경우 `지금부터 쉬기 / 다음 목표 이어가기 / 한 조각 다시 잡기` 중 하나를 고를 수 있다.
|
||||
- `다음 목표 이어가기`는 goal complete next view로 바로 연결된다.
|
||||
- `/app`을 single-goal commitment gate로 다시 줄였다.
|
||||
- 2-step ritual setup을 제거했다.
|
||||
- current session이 있으면 `Resume` UI만 보여주고, `/space`로 이어가기만 제안한다.
|
||||
- current session이 없으면 `goal 1개 + optional microStep 1개 + 바로 시작`만 남긴다.
|
||||
- scene/sound/timer는 기본 ritual로 시작하고 `/space`에서 바꾸게 둔다.
|
||||
- manage/list 성격의 affordance는 메인 진입 경로에서 제거했다.
|
||||
- `/space`는 execution-only surface로 정리됐다.
|
||||
- setup drawer에서 Daily Plan / Ritual Library 섹션을 제거했다.
|
||||
- goal, scene, sound, timer만 확인하고 focus HUD로 진입한다.
|
||||
|
||||
93
docs/work.md
93
docs/work.md
@@ -17,62 +17,89 @@
|
||||
|
||||
## 작업 1
|
||||
|
||||
- 제목: Focus Entry Surface /space linked flow 브라우저 QA
|
||||
- 제목: `/space` Refocus polish 마무리
|
||||
- 목적:
|
||||
- `/app`이 planning home이 아니라 focus entry surface로 보이는지 확인한다.
|
||||
- hero input + primary CTA만으로 `/space` 진입이 가능한지 검증한다.
|
||||
- `10_refocus_system_spec.md` 기준으로 pause / refocus / next beat / goal complete를 premium recovery flow로 정리한다.
|
||||
- recovery overlay가 planner/checklist처럼 보이지 않게 한다.
|
||||
- 변경 범위:
|
||||
- `/app` hero input / placeholder / suggestion chip / start CTA 확인
|
||||
- `블록 정리` sheet 열기, row 선택, row 수정, row 삭제 확인
|
||||
- Free 1개 / Pro 5개 제한 확인
|
||||
- pause tray
|
||||
- refocus tray
|
||||
- next beat tray
|
||||
- goal complete tray
|
||||
- copy / hierarchy / material / motion polish
|
||||
- 제외 범위:
|
||||
- 실제 결제 연동 금지
|
||||
- calendar/task 외부 연동 금지
|
||||
- multi-goal / list affordance 추가 금지
|
||||
- social/accountability 확장 금지
|
||||
- review 통계 확장 금지
|
||||
- 완료 조건:
|
||||
- `/app`이 리스트 CRUD보다 `지금 시작` hero가 먼저 읽힌다.
|
||||
- empty state에서도 disabled primary CTA 없이 `/space` 진입 경로가 살아 있다.
|
||||
- manage sheet 안에서만 add/edit/delete가 보이고, 메인 화면에는 row-level 관리 버튼이 없다.
|
||||
- start link에 담긴 goal/plan item이 `/space` 세션 시작까지 유지된다.
|
||||
- refocus가 `편집 기능`이 아니라 `recovery ritual`처럼 읽힌다.
|
||||
- pause / next beat / complete가 한 번에 하나만 보인다.
|
||||
- bright/dark scene 모두에서 안정적으로 읽힌다.
|
||||
- 검증:
|
||||
- 브라우저 수동 확인
|
||||
- 커밋 힌트:
|
||||
- chore(qa): focus-entry-surface smoke
|
||||
- feat(space): refocus-system polish
|
||||
|
||||
## 작업 2
|
||||
|
||||
- 제목: 목표 완료 후 다음 목표 즉시 실행 QA
|
||||
- 제목: `Away / Return Recovery` 구현
|
||||
- 목적:
|
||||
- `/space`가 execution-only surface로 보이는지 확인한다.
|
||||
- goal complete sheet에서 다음 목표를 입력하면 setup으로 돌아가지 않고 즉시 다음 세션이 시작되는지 검증한다.
|
||||
- `11_away_return_recovery_spec.md` 기준으로 pause 없이 떠난 사용자의 복귀 흐름을 구현한다.
|
||||
- `pause`, `break`, `return`이 같은 멈춤 상태처럼 읽히지 않게 한다.
|
||||
- 변경 범위:
|
||||
- `/space?goal=...&planItemId=...` 진입 확인
|
||||
- goal complete -> next goal -> running session 전환 확인
|
||||
- scene/sound/timer 유지 여부 확인
|
||||
- `visibilitychange`
|
||||
- `pagehide`
|
||||
- sleep/wake delta 감지
|
||||
- return tray
|
||||
- focus ended while away 처리
|
||||
- 제외 범위:
|
||||
- timer 종료 자동 전환 추가 금지
|
||||
- ritual/template persistence 추가 금지
|
||||
- webcam / idle tracking / 감시성 기능 금지
|
||||
- planner/list affordance 추가 금지
|
||||
- break를 standard pause처럼 재사용 금지
|
||||
- 완료 조건:
|
||||
- 현재 목표 완료 시 linked plan item이 완료 처리되고, 새 목표가 즉시 running session으로 이어진다.
|
||||
- `/space` setup drawer에 planning/ritual 섹션이 남아 있지 않다.
|
||||
- focus가 끝난 뒤 복귀하면 바로 standard break로 가지 않는다.
|
||||
- return 상태에서 `이어가기 / 한 조각 다시 잡기 / 지금부터 쉬기` 중 적절한 제안이 나온다.
|
||||
- 검증:
|
||||
- 브라우저 수동 확인
|
||||
- 브라우저 수동 확인 + 상태 전이 점검
|
||||
- 커밋 힌트:
|
||||
- chore(qa): advance-goal linked session smoke
|
||||
- feat(space): away-return-recovery
|
||||
|
||||
## 작업 3
|
||||
|
||||
- 제목: `/stats` factual summary QA
|
||||
- 제목: `Pause / Break / Return` 분리 polish
|
||||
- 목적:
|
||||
- `/stats`가 해석형 insight 없이 factual summary만 보여주는지 확인한다.
|
||||
- 세 상태가 감정적으로도 구조적으로도 다르게 읽히도록 정리한다.
|
||||
- 변경 범위:
|
||||
- today / last7Days factual cards 확인
|
||||
- trend 그래프와 refresh/source 상태 확인
|
||||
- 카피
|
||||
- material
|
||||
- tray hierarchy
|
||||
- timer/HUD와의 연결
|
||||
- 제외 범위:
|
||||
- 해석형 패턴 추천 추가 금지
|
||||
- social/accountability mock 복구 금지
|
||||
- review 통계 확장 금지
|
||||
- social/accountability 확장 금지
|
||||
- 완료 조건:
|
||||
- `/stats`에서 started/completed/carried over/focus minutes만 일관되게 보인다.
|
||||
- pause는 recovery tone
|
||||
- break는 release tone
|
||||
- return은 re-entry tone으로 분리된다
|
||||
- 검증:
|
||||
- 브라우저 수동 확인
|
||||
- 커밋 힌트:
|
||||
- chore(qa): stats factual summary smoke
|
||||
- feat(space): separate-pause-break-return
|
||||
|
||||
## 작업 4
|
||||
|
||||
- 제목: `Weekly Review` 상세 기획
|
||||
- 목적:
|
||||
- total time 중심이 아니라 행동 변화 중심의 review를 설계한다.
|
||||
- 변경 범위:
|
||||
- started / resumed / completed / recovery rate / ritual fit 정의
|
||||
- free / pro review 가치 구분
|
||||
- 제외 범위:
|
||||
- planner/dashboard 확장 금지
|
||||
- 해석 과잉 카피 금지
|
||||
- 완료 조건:
|
||||
- review가 다음 세션 성공률을 높이는 역할로 정의된다.
|
||||
- 검증:
|
||||
- 기획 문서 작성
|
||||
- 커밋 힌트:
|
||||
- docs(product): weekly-review-spec
|
||||
|
||||
@@ -42,14 +42,37 @@ export const space = {
|
||||
refocusTitle: '다시 방향 잡기',
|
||||
refocusDescription: '딱 한 줄만 다듬고 다시 시작해요.',
|
||||
refocusApply: '적용',
|
||||
refocusApplyAndResume: '적용하고 이어가기',
|
||||
refocusApplying: '적용 중…',
|
||||
refocusSaved: '이번 세션 방향을 다듬었어요.',
|
||||
refocusOpenOnPause: '잠시 멈춘 김에 다음 한 조각을 다시 맞춰볼까요?',
|
||||
pausePromptEyebrow: '잠깐 멈춤',
|
||||
pausePromptTitle: '다시 붙잡을 한 조각만 정하면 돼요.',
|
||||
pausePromptDescription: '왜 멈췄는지는 묻지 않아요. 다시 시작할 한 조각만 남겨요.',
|
||||
pausePromptRefocus: '한 조각 다시 잡기',
|
||||
pausePromptRefocusHint: '목표는 그대로 두고, 지금 다시 시작할 한 줄만 정리해요.',
|
||||
pausePromptKeep: '이대로 이어가기',
|
||||
pausePromptKeepHint: '지금 방향을 그대로 유지한 채 바로 이어서 시작해요.',
|
||||
returnPromptEyebrow: '다시 돌아왔어요',
|
||||
returnPromptFocusTitle: '이어서 갈까요?',
|
||||
returnPromptFocusDescription: '흐름은 그대로 남아 있어요. 바로 이어가거나 한 조각만 다시 잡을 수 있어요.',
|
||||
returnPromptBreakTitle: '자리를 비운 사이 이 블록이 끝났어요.',
|
||||
returnPromptBreakDescription: '지금부터 쉬거나, 다음으로 이어갈 수 있어요.',
|
||||
returnPromptContinue: '이어서 하기',
|
||||
returnPromptContinueHint: '타이머와 흐름을 그대로 둔 채 다시 집중으로 돌아갑니다.',
|
||||
returnPromptRest: '지금부터 쉬기',
|
||||
returnPromptRestHint: '지금부터 break를 시작한 것처럼 천천히 숨을 고릅니다.',
|
||||
returnPromptNext: '다음 목표 이어가기',
|
||||
returnPromptNextHint: '다음 한 조각을 바로 정하고 흐름을 끊지 않고 잇습니다.',
|
||||
returnPromptRefocus: '한 조각 다시 잡기',
|
||||
returnPromptRefocusHint: '왜 멈췄는지는 건너뛰고, 지금 다시 시작할 한 줄만 남깁니다.',
|
||||
microStepCompleteAriaLabel: '현재 한 조각 완료',
|
||||
microStepPromptTitle: '다음 한 조각이 있나요?',
|
||||
microStepPromptDescription: '리스트를 만들지 않고, 지금 다시 시작할 한 조각만 정해요.',
|
||||
microStepPromptKeep: '이 목표만 유지',
|
||||
microStepPromptKeepHint: '다음 한 조각은 비워두고, 같은 목표만 유지해요.',
|
||||
microStepPromptDefine: '한 조각 정하기',
|
||||
microStepPromptDefineHint: '바로 손을 움직일 수 있는 다음 한 줄만 정해요.',
|
||||
microStepCleared: '지금 할 한 조각을 비우고 목표만 유지해요.',
|
||||
completeAction: '이번 목표 완료',
|
||||
},
|
||||
@@ -59,10 +82,19 @@ export const space = {
|
||||
placeholderExample: (goal: string) => `예: ${goal}`,
|
||||
title: '좋아요. 다음 한 조각은?',
|
||||
description: '너무 크게 잡지 말고, 바로 다음 한 조각만.',
|
||||
currentGoalLabel: '끝낸 목표',
|
||||
nextGoalLabel: '다음 목표',
|
||||
chooseNextButton: '다음 목표 이어가기',
|
||||
chooseNextDescription: '바로 이어갈 다음 한 조각을 정하고 계속 갑니다.',
|
||||
backButton: '돌아가기',
|
||||
closeAriaLabel: '닫기',
|
||||
finishButton: '여기까지 끝내기',
|
||||
finishDescription: '이 블록은 여기서 닫고, 다음 진입은 가볍게 남겨둡니다.',
|
||||
restButton: '잠깐 쉬기',
|
||||
restDescription: '지금은 멈추고 숨을 고른 뒤 돌아올 여지를 남겨둡니다.',
|
||||
confirmButton: '다음 목표로 바로 시작',
|
||||
confirmPending: '시작 중…',
|
||||
finishPending: '마무리 중…',
|
||||
},
|
||||
controlCenter: {
|
||||
sectionTitles: {
|
||||
|
||||
@@ -1,369 +1,298 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type FocusPlanItem,
|
||||
type FocusPlanToday,
|
||||
useFocusPlan,
|
||||
} from '@/entities/focus-plan';
|
||||
import { usePlanTier } from '@/entities/plan';
|
||||
import { SCENE_THEMES, getSceneById } from '@/entities/scene';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMediaCatalog, getSceneStageBackgroundStyle } from '@/entities/media';
|
||||
import { usePlanTier } from '@/entities/plan';
|
||||
import { getSceneById, SCENE_THEMES } from '@/entities/scene';
|
||||
import { SOUND_PRESETS } from '@/entities/session';
|
||||
import { PaywallSheetContent } from '@/features/paywall-sheet';
|
||||
import { PlanPill } from '@/features/plan-pill';
|
||||
import { focusSessionApi } from '@/features/focus-session/api/focusSessionApi';
|
||||
import { focusSessionApi, type FocusSession } from '@/features/focus-session/api/focusSessionApi';
|
||||
import { copy } from '@/shared/i18n';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { useDragScroll } from '@/shared/lib/useDragScroll';
|
||||
import { FocusPlanManageSheet, type FocusPlanEditingState } from './FocusPlanManageSheet';
|
||||
|
||||
const FREE_MAX_ITEMS = 1;
|
||||
const PRO_MAX_ITEMS = 5;
|
||||
const DEFAULT_SCENE_ID = getSceneById('forest')?.id ?? SCENE_THEMES[0].id;
|
||||
const DEFAULT_SOUND_ID = SOUND_PRESETS.find((preset) => preset.id === 'forest-birds')?.id ?? SOUND_PRESETS[0].id;
|
||||
const DEFAULT_TIMER_ID = '50-10';
|
||||
const GOAL_SUGGESTIONS = copy.session.goalChips.slice(0, 4);
|
||||
|
||||
const focusEntryCopy = {
|
||||
const entryCopy = {
|
||||
eyebrow: 'VibeRoom',
|
||||
title: '오늘의 깊은 몰입을 위한 단 하나의 목표',
|
||||
description: '지금 당장 시작할 딱 하나만 남겨두세요.',
|
||||
inputLabel: '첫 블록',
|
||||
inputPlaceholder: '예: 제안서 첫 문단만 다듬기',
|
||||
helper: '아주 작게 잡아도 괜찮아요.',
|
||||
startNow: '바로 몰입하기',
|
||||
nextStep: '환경 세팅',
|
||||
manageBlocks: '내 계획에서 가져오기',
|
||||
previewTitle: '이어갈 블록',
|
||||
previewDescription: '다음 후보는 가볍게만 두고, 시작은 위 버튼 하나로 끝냅니다.',
|
||||
reviewLinkLabel: 'stats',
|
||||
reviewFallback: '최근 7일 흐름을 불러오는 중이에요.',
|
||||
ritualMeta: '기본 설정으로 들어갑니다. 공간 안에서 언제든 바꿀 수 있어요.',
|
||||
apiUnavailableNote: '계획 연결이 잠시 느려요. 지금은 첫 블록부터 바로 시작할 수 있어요.',
|
||||
freeUpgradeLabel: '두 번째 블록부터는 PRO',
|
||||
paywallSource: 'focus-entry-manage-sheet',
|
||||
title: '지금 붙잡을 한 가지',
|
||||
description: '길게 정리하지 말고, 한 줄만 남기고 바로 들어가요.',
|
||||
goalPlaceholder: '예: 제안서 첫 문단만 다듬기',
|
||||
microStepLabel: '지금 할 한 조각',
|
||||
microStepPlaceholder: '예: 파일 열고 첫 문장만 정리하기',
|
||||
microStepHelper: '선택 사항이에요. 바로 손이 가게 만드는 한 조각이면 충분해요.',
|
||||
startNow: '지금 시작',
|
||||
startLoading: '몰입 준비 중...',
|
||||
ritualHint: '기본 ritual · 숲 · 50/10 · Forest Birds',
|
||||
ritualHelper: '공간과 사운드는 들어간 뒤에도 바꿀 수 있어요.',
|
||||
resumeEyebrow: 'Resume',
|
||||
resumeRunning: '진행 중인 세션이 있어요.',
|
||||
resumePaused: '잠시 멈춘 세션이 있어요.',
|
||||
resumeCta: '이어서 들어가기',
|
||||
resumeMicroStepLabel: '마지막 한 조각',
|
||||
resumeNewGoalHint: '새 목표는 현재 세션을 마무리한 뒤 시작할 수 있어요.',
|
||||
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.',
|
||||
paywallLead: 'Calm Session OS PRO',
|
||||
paywallBody: '여러 블록을 이어서 정리하는 manage sheet는 PRO에서 열립니다.',
|
||||
microStepTitle: '가장 작은 첫 단계 (선택)',
|
||||
microStepHelper: '이 목표를 위해 당장 할 수 있는 5분짜리 행동은 무엇인가요?',
|
||||
microStepPlaceholder: '예: 폴더 열기, 노션 켜기',
|
||||
ritualTitle: '어떤 환경에서 몰입하시겠어요?',
|
||||
ritualHelper: '오늘의 무드를 선택하세요.',
|
||||
paywallBody: 'Pro는 더 빠른 ritual과 더 깊은 review로 시작과 복귀를 가볍게 만듭니다.',
|
||||
};
|
||||
|
||||
const ENTRY_SUGGESTIONS = [
|
||||
{ id: 'tidy-10m', label: '정리 10분', goal: '정리 10분만 하기' },
|
||||
{ id: 'mail-3', label: '메일 3개', goal: '메일 3개 정리' },
|
||||
{ id: 'doc-1p', label: '문서 1p', goal: '문서 1p 다듬기' },
|
||||
] as const;
|
||||
const goalCardClass =
|
||||
'w-full rounded-[2rem] border border-white/12 bg-[#0f1115]/26 px-6 py-6 shadow-[0_24px_60px_rgba(3,7,18,0.32)] backdrop-blur-xl md:px-8 md:py-8';
|
||||
const inputShellClass =
|
||||
'w-full rounded-[1.75rem] border border-white/14 bg-white/[0.06] px-5 py-4 text-white outline-none transition focus:border-white/24 focus:bg-white/[0.09]';
|
||||
const primaryButtonClass =
|
||||
'inline-flex items-center justify-center rounded-full border border-white/16 bg-white/[0.14] px-6 py-3 text-sm font-medium text-white transition hover:bg-white/[0.18] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-48';
|
||||
|
||||
type EntrySource = 'starter' | 'plan' | 'custom';
|
||||
type DashboardStep = 'goal' | 'ritual';
|
||||
|
||||
const getVisiblePlanItems = (
|
||||
currentItem: FocusPlanItem | null,
|
||||
nextItems: FocusPlanItem[],
|
||||
limit: number,
|
||||
) => {
|
||||
return [currentItem, ...nextItems]
|
||||
.filter((item): item is FocusPlanItem => Boolean(item))
|
||||
.slice(0, limit);
|
||||
const timerLabelById: Record<string, string> = {
|
||||
'25-5': '25/5',
|
||||
'50-10': '50/10',
|
||||
'90-20': '90/20',
|
||||
};
|
||||
|
||||
const resolveVisiblePlanItems = (nextPlan: FocusPlanToday | null, limit: number) => {
|
||||
return getVisiblePlanItems(nextPlan?.currentItem ?? null, nextPlan?.nextItems ?? [], limit);
|
||||
};
|
||||
const resolveSoundLabel = (soundPresetId?: string | null) => {
|
||||
if (!soundPresetId) {
|
||||
return 'Silent';
|
||||
}
|
||||
|
||||
// Premium Glassmorphism UI Classes
|
||||
const glassInputClass = 'w-full rounded-full border border-white/20 bg-black/20 px-8 py-5 text-center text-lg md:text-xl font-light tracking-wide text-white placeholder:text-white/40 shadow-2xl backdrop-blur-xl outline-none transition-all focus:border-white/40 focus:bg-black/30 focus:ring-4 focus:ring-white/10';
|
||||
const primaryGlassBtnClass = 'inline-flex items-center justify-center rounded-full border border-white/20 bg-white/20 px-8 py-4 text-base font-medium text-white shadow-xl backdrop-blur-xl transition-all hover:bg-white/30 hover:scale-[1.02] active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
const secondaryGlassBtnClass = 'inline-flex items-center justify-center rounded-full border border-white/10 bg-transparent px-8 py-4 text-base font-medium text-white/80 transition-all hover:bg-white/10 hover:text-white active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
const panelGlassClass = 'rounded-[2rem] border border-white/10 bg-black/40 p-6 md:p-8 shadow-2xl backdrop-blur-2xl';
|
||||
const itemCardGlassClass = 'relative flex flex-col items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-4 text-white transition-all hover:bg-white/10 active:scale-95 cursor-pointer';
|
||||
const itemCardGlassSelectedClass = 'border-white/40 bg-white/20 shadow-[0_0_20px_rgba(255,255,255,0.1)]';
|
||||
return SOUND_PRESETS.find((preset) => preset.id === soundPresetId)?.label ?? 'Silent';
|
||||
};
|
||||
|
||||
export const FocusDashboardWidget = () => {
|
||||
const router = useRouter();
|
||||
const { plan: planTier, isPro, setPlan } = usePlanTier();
|
||||
const { plan, isLoading, isSaving, source, createItem, updateItem, deleteItem } = useFocusPlan();
|
||||
const { plan, isPro, setPlan } = usePlanTier();
|
||||
const { sceneAssetMap } = useMediaCatalog();
|
||||
|
||||
const [step, setStep] = useState<DashboardStep>('goal');
|
||||
const [paywallSource, setPaywallSource] = useState<string | null>(null);
|
||||
const [manageSheetOpen, setManageSheetOpen] = useState(false);
|
||||
const [editingState, setEditingState] = useState<FocusPlanEditingState>(null);
|
||||
|
||||
const [entryDraft, setEntryDraft] = useState('');
|
||||
const [selectedPlanItemId, setSelectedPlanItemId] = useState<string | null>(null);
|
||||
const [entrySource, setEntrySource] = useState<EntrySource>('starter');
|
||||
|
||||
const [goalDraft, setGoalDraft] = useState('');
|
||||
const [microStepDraft, setMicroStepDraft] = useState('');
|
||||
// Use user's last preference or default to first
|
||||
const [selectedSceneId, setSelectedSceneId] = useState(SCENE_THEMES[0].id);
|
||||
const [selectedSoundId, setSelectedSoundId] = useState(SOUND_PRESETS[0].id);
|
||||
const [selectedTimerId, setSelectedTimerId] = useState('50-10');
|
||||
|
||||
const [isStartingSession, setIsStartingSession] = useState(false);
|
||||
const [paywallSource, setPaywallSource] = useState<string | null>(null);
|
||||
const [currentSession, setCurrentSession] = useState<FocusSession | null>(null);
|
||||
const [isCheckingSession, setIsCheckingSession] = useState(true);
|
||||
const [sessionLookupError, setSessionLookupError] = useState<string | null>(null);
|
||||
|
||||
const entryInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const microStepInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const goalInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const {
|
||||
containerRef: sceneContainerRef,
|
||||
events: sceneDragEvents,
|
||||
isDragging: isSceneDragging,
|
||||
shouldSuppressClick: shouldSuppressSceneClick,
|
||||
} = useDragScroll();
|
||||
const activeScene = useMemo(() => {
|
||||
return getSceneById(currentSession?.sceneId ?? DEFAULT_SCENE_ID) ?? SCENE_THEMES[0];
|
||||
}, [currentSession?.sceneId]);
|
||||
|
||||
const selectedScene = useMemo(() => getSceneById(selectedSceneId) ?? SCENE_THEMES[0], [selectedSceneId]);
|
||||
const activeRitualMeta = useMemo(() => {
|
||||
const timerLabel = timerLabelById[currentSession?.timerPresetId ?? DEFAULT_TIMER_ID] ?? '50/10';
|
||||
const soundLabel = resolveSoundLabel(currentSession?.soundPresetId ?? DEFAULT_SOUND_ID);
|
||||
|
||||
const maxItems = isPro ? PRO_MAX_ITEMS : FREE_MAX_ITEMS;
|
||||
const planItems = useMemo(() => {
|
||||
return getVisiblePlanItems(plan.currentItem, plan.nextItems, maxItems);
|
||||
}, [maxItems, plan.currentItem, plan.nextItems]);
|
||||
return `${activeScene.name} · ${timerLabel} · ${soundLabel}`;
|
||||
}, [activeScene.name, currentSession?.soundPresetId, currentSession?.timerPresetId]);
|
||||
|
||||
const currentItem = planItems[0] ?? null;
|
||||
const shouldUseCurrentPlanDefaults =
|
||||
Boolean(currentItem) && (entrySource === 'starter' || (entrySource === 'plan' && !selectedPlanItemId));
|
||||
const resolvedEntryDraft = shouldUseCurrentPlanDefaults && currentItem ? currentItem.title : entryDraft;
|
||||
const resolvedSelectedPlanItemId =
|
||||
shouldUseCurrentPlanDefaults && currentItem ? currentItem.id : selectedPlanItemId;
|
||||
|
||||
const hasPendingEdit = editingState !== null;
|
||||
const canAddMore = planItems.length < maxItems;
|
||||
const canManagePlan = source === 'api' && !isLoading;
|
||||
const trimmedEntryGoal = resolvedEntryDraft.trim();
|
||||
const isGoalReady = trimmedEntryGoal.length > 0;
|
||||
const trimmedGoal = goalDraft.trim();
|
||||
const canStart = trimmedGoal.length > 0 && !isStartingSession && !currentSession;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingState) return;
|
||||
const rafId = window.requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(rafId);
|
||||
}, [editingState]);
|
||||
let cancelled = false;
|
||||
|
||||
const openPaywall = () => setPaywallSource(focusEntryCopy.paywallSource);
|
||||
const loadCurrentSession = async () => {
|
||||
setIsCheckingSession(true);
|
||||
|
||||
const handleSelectPlanItem = (item: FocusPlanItem) => {
|
||||
const isCurrentSelection = currentItem?.id === item.id;
|
||||
setEntryDraft(item.title);
|
||||
setSelectedPlanItemId(isCurrentSelection ? item.id : null);
|
||||
setEntrySource(isCurrentSelection ? 'plan' : 'custom');
|
||||
setManageSheetOpen(false);
|
||||
};
|
||||
|
||||
const handleSelectSuggestion = (goal: string) => {
|
||||
setEntryDraft(goal);
|
||||
setSelectedPlanItemId(null);
|
||||
setEntrySource('custom');
|
||||
};
|
||||
|
||||
const handleEntryDraftChange = (value: string) => {
|
||||
setEntryDraft(value);
|
||||
setEntrySource('custom');
|
||||
setSelectedPlanItemId(null);
|
||||
};
|
||||
|
||||
const handleAddBlock = () => {
|
||||
if (hasPendingEdit || isSaving || !canManagePlan) return;
|
||||
if (!canAddMore) {
|
||||
if (!isPro) openPaywall();
|
||||
try {
|
||||
const session = await focusSessionApi.getCurrentSession();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setEditingState({ mode: 'new', value: '' });
|
||||
};
|
||||
|
||||
const handleEditRow = (item: FocusPlanItem) => {
|
||||
if (hasPendingEdit || isSaving) return;
|
||||
setEditingState({ mode: 'edit', itemId: item.id, value: item.title });
|
||||
};
|
||||
|
||||
const handleManageDraftChange = (value: string) => {
|
||||
setEditingState((current) => current ? { ...current, value } : current);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
if (!isSaving) setEditingState(null);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingState) return;
|
||||
const trimmedTitle = editingState.value.trim();
|
||||
if (!trimmedTitle) return;
|
||||
|
||||
if (editingState.mode === 'new') {
|
||||
const nextPlan = await createItem({ title: trimmedTitle });
|
||||
if (!nextPlan) return;
|
||||
setEditingState(null);
|
||||
if (!currentItem) {
|
||||
const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems);
|
||||
const nextCurrentItem = nextVisiblePlanItems[0] ?? null;
|
||||
if (nextCurrentItem) {
|
||||
setEntryDraft(nextCurrentItem.title);
|
||||
setSelectedPlanItemId(nextCurrentItem.id);
|
||||
setEntrySource('plan');
|
||||
}
|
||||
}
|
||||
setCurrentSession(session);
|
||||
setSessionLookupError(null);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRow = planItems.find((item) => item.id === editingState.itemId);
|
||||
if (!currentRow) return;
|
||||
if (currentRow.title === trimmedTitle) {
|
||||
setEditingState(null);
|
||||
return;
|
||||
setCurrentSession(null);
|
||||
setSessionLookupError(error instanceof Error ? error.message : entryCopy.loadFailed);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsCheckingSession(false);
|
||||
}
|
||||
|
||||
const nextPlan = await updateItem(editingState.itemId, { title: trimmedTitle });
|
||||
if (!nextPlan) return;
|
||||
setEditingState(null);
|
||||
if (resolvedSelectedPlanItemId === editingState.itemId) {
|
||||
setEntryDraft(trimmedTitle);
|
||||
setEntrySource('plan');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRow = async (itemId: string) => {
|
||||
const nextPlan = await deleteItem(itemId);
|
||||
if (!nextPlan) return;
|
||||
if (editingState?.mode === 'edit' && editingState.itemId === itemId) {
|
||||
setEditingState(null);
|
||||
}
|
||||
if (resolvedSelectedPlanItemId === itemId) {
|
||||
const nextVisiblePlanItems = resolveVisiblePlanItems(nextPlan, maxItems);
|
||||
const nextCurrentItem = nextVisiblePlanItems[0] ?? null;
|
||||
if (nextCurrentItem) {
|
||||
setEntryDraft(nextCurrentItem.title);
|
||||
setSelectedPlanItemId(nextCurrentItem.id);
|
||||
setEntrySource('plan');
|
||||
return;
|
||||
}
|
||||
setEntryDraft('');
|
||||
setSelectedPlanItemId(null);
|
||||
setEntrySource('custom');
|
||||
void loadCurrentSession();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const openPaywall = () => {
|
||||
if (!isPro) {
|
||||
setPaywallSource('app-entry-plan-pill');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (!isGoalReady) {
|
||||
entryInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (step === 'goal') setStep('ritual');
|
||||
const handleSelectSuggestion = (label: string) => {
|
||||
setGoalDraft(label);
|
||||
goalInputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleStartSession = async () => {
|
||||
if (isStartingSession) return;
|
||||
if (!trimmedGoal || isStartingSession || currentSession) {
|
||||
if (!trimmedGoal) {
|
||||
goalInputRef.current?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsStartingSession(true);
|
||||
|
||||
try {
|
||||
await focusSessionApi.startSession({
|
||||
goal: trimmedEntryGoal,
|
||||
goal: trimmedGoal,
|
||||
microStep: microStepDraft.trim() || null,
|
||||
sceneId: selectedSceneId,
|
||||
soundPresetId: selectedSoundId,
|
||||
timerPresetId: selectedTimerId,
|
||||
focusPlanItemId: resolvedSelectedPlanItemId || undefined,
|
||||
entryPoint: 'space-setup'
|
||||
sceneId: DEFAULT_SCENE_ID,
|
||||
soundPresetId: DEFAULT_SOUND_ID,
|
||||
timerPresetId: DEFAULT_TIMER_ID,
|
||||
entryPoint: 'space-setup',
|
||||
});
|
||||
router.push('/space');
|
||||
} catch (err) {
|
||||
console.error('Failed to start session', err);
|
||||
setIsStartingSession(false);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Failed to start focus session from /app', error);
|
||||
}
|
||||
|
||||
setIsStartingSession(false);
|
||||
};
|
||||
|
||||
const handleResumeSession = () => {
|
||||
router.push('/space');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-dvh overflow-hidden bg-slate-900 text-white font-sans selection:bg-white/20">
|
||||
{/* Premium Cinematic Background */}
|
||||
<div className="relative min-h-dvh overflow-hidden bg-slate-950 text-white selection:bg-white/20">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 bg-cover bg-center transition-all duration-1000 ease-out will-change-transform",
|
||||
isStartingSession ? 'scale-110 blur-2xl opacity-0' : 'scale-100 opacity-100',
|
||||
step === 'ritual' ? 'scale-105 blur-sm' : ''
|
||||
'absolute inset-0 bg-cover bg-center transition-transform duration-700 ease-out',
|
||||
isStartingSession ? 'scale-[1.04]' : 'scale-100',
|
||||
)}
|
||||
style={getSceneStageBackgroundStyle(selectedScene, sceneAssetMap?.[selectedScene.id])}
|
||||
style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.id])}
|
||||
/>
|
||||
{/* Global Gradient Overlay for text readability */}
|
||||
<div className={cn(
|
||||
"absolute inset-0 bg-gradient-to-b from-black/20 via-black/40 to-black/60 transition-opacity duration-1000",
|
||||
step === 'ritual' ? 'opacity-80' : 'opacity-100'
|
||||
)} />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.22)_0%,rgba(2,6,23,0.38)_55%,rgba(2,6,23,0.5)_100%)]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.06),rgba(255,255,255,0)_42%)]" />
|
||||
|
||||
{/* Header */}
|
||||
<header className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-6 md:p-8">
|
||||
<p className="text-sm font-semibold tracking-[0.3em] text-white/50 uppercase">
|
||||
{focusEntryCopy.eyebrow}
|
||||
<header className="relative z-10 flex items-center justify-between px-5 py-5 md:px-8 md:py-7">
|
||||
<p className="text-sm font-semibold tracking-[0.28em] text-white/56 uppercase">
|
||||
{entryCopy.eyebrow}
|
||||
</p>
|
||||
<PlanPill
|
||||
plan={planTier}
|
||||
onClick={() => {
|
||||
if (!isPro) openPaywall();
|
||||
}}
|
||||
/>
|
||||
<PlanPill plan={plan} onClick={openPaywall} />
|
||||
</header>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="relative z-10 flex h-dvh flex-col items-center justify-center px-4">
|
||||
|
||||
{/* Step 1: Goal Setup */}
|
||||
<div className={cn(
|
||||
"w-full max-w-2xl transition-all duration-700 absolute",
|
||||
step === 'goal'
|
||||
? 'opacity-100 translate-y-0 pointer-events-auto'
|
||||
: 'opacity-0 -translate-y-8 pointer-events-none'
|
||||
)}>
|
||||
<div className="flex flex-col items-center space-y-10 text-center">
|
||||
<h1 className="text-3xl md:text-5xl font-light tracking-tight text-white drop-shadow-lg leading-tight">
|
||||
{focusEntryCopy.title}
|
||||
<main className="relative z-10 flex min-h-[calc(100dvh-84px)] items-center justify-center px-4 pb-8 pt-4 md:px-6">
|
||||
<div className="w-full max-w-[42rem]">
|
||||
{isCheckingSession ? (
|
||||
<div className={cn(goalCardClass, 'space-y-4 text-center')}>
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
|
||||
{entryCopy.resumeEyebrow}
|
||||
</p>
|
||||
<p className="text-[15px] text-white/72">세션 상태를 불러오는 중이에요.</p>
|
||||
</div>
|
||||
) : currentSession ? (
|
||||
<div className={cn(goalCardClass, 'space-y-5')}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46">
|
||||
{entryCopy.resumeEyebrow}
|
||||
</p>
|
||||
<h1 className="text-[1.8rem] font-light leading-[1.14] tracking-[-0.03em] text-white md:text-[2.2rem]">
|
||||
{currentSession.goal}
|
||||
</h1>
|
||||
|
||||
<div className="w-full max-w-xl mx-auto space-y-8">
|
||||
<input
|
||||
ref={entryInputRef}
|
||||
value={resolvedEntryDraft}
|
||||
onChange={(event) => handleEntryDraftChange(event.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleNextStep()}
|
||||
placeholder={focusEntryCopy.inputPlaceholder}
|
||||
className={glassInputClass}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartSession}
|
||||
disabled={!isGoalReady || isStartingSession}
|
||||
className={primaryGlassBtnClass}
|
||||
>
|
||||
{focusEntryCopy.startNow}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextStep}
|
||||
disabled={!isGoalReady || isStartingSession}
|
||||
className={secondaryGlassBtnClass}
|
||||
>
|
||||
{focusEntryCopy.nextStep}
|
||||
</button>
|
||||
<p className="text-sm text-white/68">
|
||||
{currentSession.state === 'paused' ? entryCopy.resumePaused : entryCopy.resumeRunning}
|
||||
</p>
|
||||
{currentSession.microStep ? (
|
||||
<div className="rounded-[1.1rem] border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<p className="mb-1 text-[11px] font-medium uppercase tracking-[0.16em] text-white/44">
|
||||
{entryCopy.resumeMicroStepLabel}
|
||||
</p>
|
||||
<p className="text-[15px] text-white/82">{currentSession.microStep}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Suggestions / Manage - very minimal */}
|
||||
<div className="pt-8 flex flex-col items-center gap-4 opacity-70 hover:opacity-100 transition-opacity">
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{ENTRY_SUGGESTIONS.map((suggestion) => {
|
||||
const isActive = resolvedSelectedPlanItemId === null && trimmedEntryGoal === suggestion.goal;
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button type="button" onClick={handleResumeSession} className={primaryButtonClass}>
|
||||
{entryCopy.resumeCta}
|
||||
</button>
|
||||
<p className="text-xs text-white/48 sm:text-right">{activeRitualMeta}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-white/56">{entryCopy.resumeNewGoalHint}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={goalCardClass}>
|
||||
<div className="space-y-3 text-center">
|
||||
<h1 className="text-[2rem] font-light leading-[1.08] tracking-[-0.04em] text-white md:text-[2.9rem]">
|
||||
{entryCopy.title}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-[32rem] text-[15px] leading-6 text-white/70 md:text-base">
|
||||
{entryCopy.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<label className="block">
|
||||
<span className="sr-only">Goal</span>
|
||||
<input
|
||||
ref={goalInputRef}
|
||||
value={goalDraft}
|
||||
onChange={(event) => setGoalDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleStartSession();
|
||||
}
|
||||
}}
|
||||
placeholder={entryCopy.goalPlaceholder}
|
||||
className={cn(
|
||||
inputShellClass,
|
||||
'text-[1.15rem] font-light tracking-[-0.02em] placeholder:text-white/34 md:text-[1.4rem]',
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-[12px] font-medium uppercase tracking-[0.16em] text-white/46">
|
||||
{entryCopy.microStepLabel}
|
||||
</span>
|
||||
<input
|
||||
value={microStepDraft}
|
||||
onChange={(event) => setMicroStepDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleStartSession();
|
||||
}
|
||||
}}
|
||||
placeholder={entryCopy.microStepPlaceholder}
|
||||
className={cn(inputShellClass, 'text-[0.98rem] placeholder:text-white/30')}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p className="text-sm text-white/48">{entryCopy.microStepHelper}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-2.5">
|
||||
{GOAL_SUGGESTIONS.map((suggestion) => {
|
||||
const isActive = trimmedGoal === suggestion.label;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={suggestion.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectSuggestion(suggestion.goal)}
|
||||
onClick={() => handleSelectSuggestion(suggestion.label)}
|
||||
className={cn(
|
||||
'rounded-full px-4 py-1.5 text-sm transition-all border',
|
||||
'rounded-full border px-3.5 py-1.5 text-sm transition',
|
||||
isActive
|
||||
? 'bg-white/20 border-white text-white'
|
||||
: 'bg-transparent border-white/20 text-white/70 hover:border-white/40 hover:text-white'
|
||||
? 'border-white/32 bg-white/14 text-white'
|
||||
: 'border-white/14 bg-white/[0.04] text-white/72 hover:border-white/22 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{suggestion.label}
|
||||
@@ -371,196 +300,47 @@ export const FocusDashboardWidget = () => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManageSheetOpen(true)}
|
||||
disabled={!canManagePlan}
|
||||
className="text-sm font-medium text-white/50 hover:text-white transition-colors underline underline-offset-4 decoration-white/20"
|
||||
>
|
||||
{focusEntryCopy.manageBlocks}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Ritual Setup */}
|
||||
<div className={cn(
|
||||
"w-full max-w-4xl transition-all duration-700 absolute",
|
||||
step === 'ritual'
|
||||
? 'opacity-100 translate-y-0 pointer-events-auto'
|
||||
: 'opacity-0 translate-y-8 pointer-events-none'
|
||||
)}>
|
||||
<div className={panelGlassClass}>
|
||||
<div className="flex items-center justify-between mb-8 pb-6 border-b border-white/10">
|
||||
<div className="flex-1">
|
||||
<p className="mb-2 text-xs uppercase tracking-widest text-white/40">Today's Focus</p>
|
||||
<p className="text-xl md:text-2xl font-light text-white truncate pr-4">{trimmedEntryGoal}</p>
|
||||
</div>
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('goal')}
|
||||
className="rounded-full border border-white/20 bg-white/5 px-4 py-2 text-sm text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12">
|
||||
<div className="space-y-8">
|
||||
{/* Microstep */}
|
||||
<div className="space-y-3">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-white/80">
|
||||
{focusEntryCopy.microStepTitle}
|
||||
</span>
|
||||
<input
|
||||
ref={microStepInputRef}
|
||||
value={microStepDraft}
|
||||
onChange={(e) => setMicroStepDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleStartSession()}
|
||||
placeholder={focusEntryCopy.microStepPlaceholder}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder:text-white/30 outline-none transition-all focus:border-white/30 focus:bg-white/10"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-white/40">{focusEntryCopy.microStepHelper}</p>
|
||||
</div>
|
||||
|
||||
{/* Timer */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-white/80">몰입 리듬</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[{id: '25-5', label: '25 / 5'}, {id: '50-10', label: '50 / 10'}, {id: '90-20', label: '90 / 20'}].map(timer => (
|
||||
<button
|
||||
key={timer.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedTimerId(timer.id)}
|
||||
className={cn(itemCardGlassClass, selectedTimerId === timer.id && itemCardGlassSelectedClass)}
|
||||
>
|
||||
<span className="text-sm font-medium">{timer.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Scene */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-white/80">배경 공간</p>
|
||||
<div
|
||||
ref={sceneContainerRef}
|
||||
{...sceneDragEvents}
|
||||
className={cn(
|
||||
"flex gap-3 overflow-x-auto pb-2 scrollbar-none",
|
||||
isSceneDragging ? "cursor-grabbing" : "cursor-grab"
|
||||
)}
|
||||
>
|
||||
{SCENE_THEMES.map(scene => (
|
||||
<button
|
||||
key={scene.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!shouldSuppressSceneClick) {
|
||||
setSelectedSceneId(scene.id);
|
||||
}
|
||||
void handleStartSession();
|
||||
}}
|
||||
className={cn(
|
||||
'group relative h-24 min-w-[120px] rounded-xl border border-white/10 text-left transition-all overflow-hidden bg-white/5 active:scale-95',
|
||||
selectedSceneId === scene.id && 'border-white/40 shadow-[0_0_20px_rgba(255,255,255,0.1)]',
|
||||
isSceneDragging && 'pointer-events-none'
|
||||
)}
|
||||
style={getSceneStageBackgroundStyle(scene, sceneAssetMap?.[scene.id])}
|
||||
disabled={!canStart}
|
||||
className={primaryButtonClass}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/40 transition-opacity group-hover:bg-black/20" />
|
||||
<span className="absolute bottom-2 left-2 text-sm font-medium z-10 text-white text-shadow-sm">{scene.name}</span>
|
||||
{selectedSceneId === scene.id && (
|
||||
<span className="absolute top-2 right-2 z-20 flex h-5 w-5 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm border border-white/40 text-white text-[10px]">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
{isStartingSession ? entryCopy.startLoading : entryCopy.startNow}
|
||||
</button>
|
||||
))}
|
||||
<div className="space-y-1 text-left sm:text-right">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-white/44">
|
||||
{entryCopy.ritualHint}
|
||||
</p>
|
||||
<p className="text-xs text-white/52">{entryCopy.ritualHelper}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sound */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-white/80">사운드</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{SOUND_PRESETS.map(sound => (
|
||||
<button
|
||||
key={sound.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSoundId(sound.id)}
|
||||
className={cn(itemCardGlassClass, "py-3", selectedSoundId === sound.id && itemCardGlassSelectedClass)}
|
||||
>
|
||||
<span className="text-sm font-medium">{sound.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 pt-6 flex justify-end border-t border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartSession}
|
||||
disabled={isStartingSession}
|
||||
className={primaryGlassBtnClass}
|
||||
>
|
||||
{isStartingSession ? '공간으로 이동 중...' : '입장하기'}
|
||||
</button>
|
||||
</div>
|
||||
{sessionLookupError ? (
|
||||
<p className="mt-5 text-sm text-amber-100/80">{entryCopy.loadFailed}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Plan Sheet & Paywall */}
|
||||
<FocusPlanManageSheet
|
||||
isOpen={manageSheetOpen}
|
||||
planItems={planItems}
|
||||
selectedPlanItemId={selectedPlanItemId}
|
||||
editingState={editingState}
|
||||
isSaving={isSaving}
|
||||
canAddMore={canAddMore}
|
||||
isPro={isPro}
|
||||
inputRef={inputRef}
|
||||
onClose={() => {
|
||||
if (!isSaving) {
|
||||
setManageSheetOpen(false);
|
||||
setEditingState(null);
|
||||
}
|
||||
}}
|
||||
onAddBlock={handleAddBlock}
|
||||
onDraftChange={handleManageDraftChange}
|
||||
onSelect={handleSelectPlanItem}
|
||||
onEdit={handleEditRow}
|
||||
onDelete={(itemId) => {
|
||||
void handleDeleteRow(itemId);
|
||||
}}
|
||||
onSave={() => {
|
||||
void handleSaveEdit();
|
||||
}}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
|
||||
{paywallSource ? (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copy.modal.closeAriaLabel}
|
||||
onClick={() => setPaywallSource(null)}
|
||||
className="absolute inset-0 bg-slate-950/48 backdrop-blur-[2px]"
|
||||
className="absolute inset-0 bg-slate-950/52 backdrop-blur-[3px]"
|
||||
/>
|
||||
<div className="relative z-10 w-full max-w-md rounded-3xl border border-white/12 bg-[linear-gradient(165deg,rgba(15,23,42,0.94)_0%,rgba(2,6,23,0.98)_100%)] p-5 shadow-[0_24px_60px_rgba(2,6,23,0.36)]">
|
||||
<p className="mb-3 text-[11px] uppercase tracking-[0.16em] text-white/42">
|
||||
{focusEntryCopy.paywallLead}
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-white/62">
|
||||
{focusEntryCopy.paywallBody}
|
||||
{entryCopy.paywallLead}
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-white/62">{entryCopy.paywallBody}</p>
|
||||
<PaywallSheetContent
|
||||
onStartPro={() => {
|
||||
setPlan('pro');
|
||||
|
||||
@@ -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>
|
||||
|
||||
{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="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"
|
||||
className={HUD_FIELD}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<footer className="mt-3 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRest}
|
||||
onClick={() => setView('choice')}
|
||||
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"
|
||||
className={HUD_TEXT_LINK}
|
||||
>
|
||||
{copy.space.goalComplete.restButton}
|
||||
{copy.space.goalComplete.backButton}
|
||||
</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"
|
||||
className={HUD_TEXT_LINK_STRONG}
|
||||
>
|
||||
{isSubmitting ? copy.space.goalComplete.confirmPending : copy.space.goalComplete.confirmButton}
|
||||
{submissionMode === 'next'
|
||||
? copy.space.goalComplete.confirmPending
|
||||
: copy.space.goalComplete.confirmButton}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<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)}
|
||||
>
|
||||
<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>
|
||||
|
||||
93
src/widgets/space-focus-hud/ui/PauseRefocusPrompt.tsx
Normal file
93
src/widgets/space-focus-hud/ui/PauseRefocusPrompt.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
157
src/widgets/space-focus-hud/ui/ReturnPrompt.tsx
Normal file
157
src/widgets/space-focus-hud/ui/ReturnPrompt.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
34
src/widgets/space-focus-hud/ui/overlayStyles.ts
Normal file
34
src/widgets/space-focus-hud/ui/overlayStyles.ts
Normal 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';
|
||||
184
src/widgets/space-workspace/model/useAwayReturnRecovery.ts
Normal file
184
src/widgets/space-workspace/model/useAwayReturnRecovery.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { FocusSession } from '@/features/focus-session';
|
||||
|
||||
const AWAY_HIDDEN_THRESHOLD_MS = 20_000;
|
||||
const AWAY_SLEEP_GAP_THRESHOLD_MS = 90_000;
|
||||
const HEARTBEAT_INTERVAL_MS = 15_000;
|
||||
|
||||
export type ReturnPromptMode = 'focus' | 'break';
|
||||
|
||||
interface UseAwayReturnRecoveryParams {
|
||||
currentSession: FocusSession | null;
|
||||
isBootstrapping: boolean;
|
||||
syncCurrentSession: () => Promise<FocusSession | null>;
|
||||
}
|
||||
|
||||
interface UseAwayReturnRecoveryResult {
|
||||
returnPromptMode: ReturnPromptMode | null;
|
||||
dismissReturnPrompt: () => void;
|
||||
}
|
||||
|
||||
export const useAwayReturnRecovery = ({
|
||||
currentSession,
|
||||
isBootstrapping,
|
||||
syncCurrentSession,
|
||||
}: UseAwayReturnRecoveryParams): UseAwayReturnRecoveryResult => {
|
||||
const [returnPromptMode, setReturnPromptMode] = useState<ReturnPromptMode | null>(null);
|
||||
const hiddenAtRef = useRef<number | null>(null);
|
||||
const awayCandidateRef = useRef(false);
|
||||
const heartbeatAtRef = useRef(Date.now());
|
||||
const isHandlingReturnRef = useRef(false);
|
||||
|
||||
const isRunningFocusSession =
|
||||
currentSession?.state === 'running' && currentSession.phase === 'focus';
|
||||
|
||||
const clearAwayCandidate = useCallback(() => {
|
||||
hiddenAtRef.current = null;
|
||||
awayCandidateRef.current = false;
|
||||
}, []);
|
||||
|
||||
const dismissReturnPrompt = useCallback(() => {
|
||||
setReturnPromptMode(null);
|
||||
clearAwayCandidate();
|
||||
}, [clearAwayCandidate]);
|
||||
|
||||
useEffect(() => {
|
||||
heartbeatAtRef.current = Date.now();
|
||||
}, [currentSession?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRunningFocusSession) {
|
||||
clearAwayCandidate();
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
heartbeatAtRef.current = Date.now();
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [clearAwayCandidate, isRunningFocusSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSession?.state !== 'running') {
|
||||
if (returnPromptMode === 'focus') {
|
||||
setReturnPromptMode(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSession.phase !== 'break' && returnPromptMode === 'break') {
|
||||
setReturnPromptMode(null);
|
||||
}
|
||||
}, [currentSession?.phase, currentSession?.state, returnPromptMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isBootstrapping) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeHandleReturn = async () => {
|
||||
if (isHandlingReturnRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hiddenDuration =
|
||||
hiddenAtRef.current == null ? 0 : Date.now() - hiddenAtRef.current;
|
||||
const sleepGap = Date.now() - heartbeatAtRef.current;
|
||||
|
||||
if (!awayCandidateRef.current) {
|
||||
if (
|
||||
isRunningFocusSession &&
|
||||
document.visibilityState === 'visible' &&
|
||||
sleepGap >= AWAY_SLEEP_GAP_THRESHOLD_MS
|
||||
) {
|
||||
awayCandidateRef.current = true;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (hiddenAtRef.current != null && hiddenDuration < AWAY_HIDDEN_THRESHOLD_MS) {
|
||||
clearAwayCandidate();
|
||||
heartbeatAtRef.current = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
isHandlingReturnRef.current = true;
|
||||
|
||||
try {
|
||||
const syncedSession = await syncCurrentSession();
|
||||
const resolvedSession = syncedSession ?? currentSession;
|
||||
|
||||
clearAwayCandidate();
|
||||
heartbeatAtRef.current = Date.now();
|
||||
|
||||
if (!resolvedSession || resolvedSession.state !== 'running') {
|
||||
setReturnPromptMode(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedSession.phase === 'focus') {
|
||||
setReturnPromptMode('focus');
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedSession.phase === 'break') {
|
||||
setReturnPromptMode('break');
|
||||
return;
|
||||
}
|
||||
|
||||
setReturnPromptMode(null);
|
||||
} finally {
|
||||
isHandlingReturnRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
if (isRunningFocusSession) {
|
||||
hiddenAtRef.current = Date.now();
|
||||
awayCandidateRef.current = true;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
void maybeHandleReturn();
|
||||
};
|
||||
|
||||
const handlePageHide = () => {
|
||||
if (isRunningFocusSession) {
|
||||
hiddenAtRef.current = Date.now();
|
||||
awayCandidateRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
void maybeHandleReturn();
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.addEventListener('pagehide', handlePageHide);
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
window.addEventListener('pageshow', handleWindowFocus);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
window.removeEventListener('pagehide', handlePageHide);
|
||||
window.removeEventListener('focus', handleWindowFocus);
|
||||
window.removeEventListener('pageshow', handleWindowFocus);
|
||||
};
|
||||
}, [clearAwayCandidate, currentSession, isBootstrapping, isRunningFocusSession, syncCurrentSession]);
|
||||
|
||||
return {
|
||||
returnPromptMode,
|
||||
dismissReturnPrompt,
|
||||
};
|
||||
};
|
||||
@@ -40,6 +40,12 @@ interface UseSpaceWorkspaceSessionControlsParams {
|
||||
goal?: string;
|
||||
microStep?: string | null;
|
||||
}) => Promise<FocusSession | null>;
|
||||
completeSession: (payload: {
|
||||
completionType: 'goal-complete' | 'timer-complete';
|
||||
completedGoal?: string;
|
||||
focusScore?: number;
|
||||
distractionCount?: number;
|
||||
}) => Promise<FocusSession | null>;
|
||||
advanceGoal: (input: {
|
||||
completedGoal: string;
|
||||
nextGoal: string;
|
||||
@@ -78,6 +84,7 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
resumeSession,
|
||||
restartCurrentPhase,
|
||||
updateCurrentIntent,
|
||||
completeSession,
|
||||
advanceGoal,
|
||||
abandonSession,
|
||||
setGoalInput,
|
||||
@@ -294,6 +301,47 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
unlockPlayback,
|
||||
]);
|
||||
|
||||
const handleGoalComplete = useCallback(async () => {
|
||||
const trimmedCurrentGoal = goalInput.trim();
|
||||
|
||||
if (!currentSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const completedSession = await completeSession({
|
||||
completionType: 'goal-complete',
|
||||
completedGoal: trimmedCurrentGoal || undefined,
|
||||
});
|
||||
|
||||
if (!completedSession) {
|
||||
pushStatusLine({
|
||||
message: copy.space.workspace.goalCompleteSyncFailed,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
setGoalInput('');
|
||||
setLinkedFocusPlanItemId(null);
|
||||
setSelectedGoalId(null);
|
||||
setShowResumePrompt(false);
|
||||
setPendingSessionEntryPoint('space-setup');
|
||||
setPreviewPlaybackState('paused');
|
||||
setWorkspaceMode('setup');
|
||||
return true;
|
||||
}, [
|
||||
completeSession,
|
||||
currentSession,
|
||||
goalInput,
|
||||
pushStatusLine,
|
||||
setGoalInput,
|
||||
setLinkedFocusPlanItemId,
|
||||
setPendingSessionEntryPoint,
|
||||
setPreviewPlaybackState,
|
||||
setSelectedGoalId,
|
||||
setShowResumePrompt,
|
||||
setWorkspaceMode,
|
||||
]);
|
||||
|
||||
const handleIntentUpdate = useCallback(async (input: {
|
||||
goal?: string;
|
||||
microStep?: string | null;
|
||||
@@ -407,6 +455,7 @@ export const useSpaceWorkspaceSessionControls = ({
|
||||
handlePauseRequested,
|
||||
handleRestartRequested,
|
||||
handleIntentUpdate,
|
||||
handleGoalComplete,
|
||||
handleGoalAdvance,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import { SpaceToolsDockWidget } from "@/widgets/space-tools-dock";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { SessionEntryPoint, WorkspaceMode } from "../model/types";
|
||||
import { useAwayReturnRecovery } from "../model/useAwayReturnRecovery";
|
||||
import { useSpaceWorkspaceSelection } from "../model/useSpaceWorkspaceSelection";
|
||||
import { useSpaceWorkspaceSessionControls } from "../model/useSpaceWorkspaceSessionControls";
|
||||
import {
|
||||
@@ -117,8 +118,10 @@ export const SpaceWorkspaceWidget = () => {
|
||||
restartCurrentPhase,
|
||||
updateCurrentIntent,
|
||||
updateCurrentSelection,
|
||||
completeSession,
|
||||
advanceGoal,
|
||||
abandonSession,
|
||||
syncCurrentSession,
|
||||
} = useFocusSessionEngine();
|
||||
|
||||
const isFocusMode = workspaceMode === "focus";
|
||||
@@ -191,6 +194,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
resumeSession,
|
||||
restartCurrentPhase,
|
||||
updateCurrentIntent,
|
||||
completeSession,
|
||||
advanceGoal,
|
||||
abandonSession,
|
||||
setGoalInput: selection.setGoalInput,
|
||||
@@ -199,6 +203,12 @@ export const SpaceWorkspaceWidget = () => {
|
||||
setShowResumePrompt: selection.setShowResumePrompt,
|
||||
});
|
||||
|
||||
const awayReturnRecovery = useAwayReturnRecovery({
|
||||
currentSession,
|
||||
isBootstrapping,
|
||||
syncCurrentSession,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isBootstrapping && !currentSession && !hasQueryOverrides) {
|
||||
router.replace("/app");
|
||||
@@ -296,6 +306,7 @@ export const SpaceWorkspaceWidget = () => {
|
||||
canStartSession={controls.canStartSession}
|
||||
canPauseSession={controls.canPauseSession}
|
||||
canRestartSession={controls.canRestartSession}
|
||||
returnPromptMode={awayReturnRecovery.returnPromptMode}
|
||||
onStartRequested={() => {
|
||||
void controls.handleStartRequested();
|
||||
}}
|
||||
@@ -305,8 +316,10 @@ export const SpaceWorkspaceWidget = () => {
|
||||
onRestartRequested={() => {
|
||||
void controls.handleRestartRequested();
|
||||
}}
|
||||
onDismissReturnPrompt={awayReturnRecovery.dismissReturnPrompt}
|
||||
onStatusMessage={pushStatusLine}
|
||||
onIntentUpdate={controls.handleIntentUpdate}
|
||||
onGoalFinish={controls.handleGoalComplete}
|
||||
onGoalUpdate={controls.handleGoalAdvance}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user