feat(core-loop): /app 진입과 /space 복구 흐름 구현

This commit is contained in:
2026-03-14 18:02:50 +09:00
parent bc08a049b6
commit b4ed94cf1b
19 changed files with 2638 additions and 619 deletions

View 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의 주 의사결정으로 느껴지지 않는다

View 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에서 나온다

View 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는 “실패”가 아니라 “복귀가 필요한 순간”으로 다룰 것

View 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

View File

@@ -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이 조금 더 강하게 느껴질 수 있어 실기기 확인이 필요하다

View File

@@ -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로 진입한다.

View File

@@ -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

View File

@@ -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: {

View File

@@ -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&apos;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');

View File

@@ -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>
);

View File

@@ -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>

View 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>
);
};

View File

@@ -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>

View 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>
);
};

View File

@@ -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);

View 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';

View 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,
};
};

View File

@@ -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,
};
};

View File

@@ -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}