Compare commits

..

35 Commits

Author SHA1 Message Date
f008af0d9b feat: 다국어 지원 (프랑스, 독일) 및 항해 화면 크루 포함 2026-02-16 23:21:31 +09:00
2ec2ba4b3a refactor: 별 속도 조정 2026-02-14 22:39:44 +09:00
25a4ebd342 refactor: 얇게 남아있던 별의 꼬리가 사라지도록 수정 2026-02-14 22:21:24 +09:00
1ccb9e517a fix: i18n의 타입 수정 2026-02-14 21:55:54 +09:00
82e2c08262 fix: 클라이언트 최초 렌더 시 hydration 에러 처리 2026-02-14 21:37:10 +09:00
332c2c5996 feat: 항해 종료 시 버튼을 꾹 눌러서 종료 2026-02-14 21:28:17 +09:00
166d04384f feat: 다국어 화 2026-02-14 03:56:03 +09:00
efdec596b2 refactor: 별 자리 좌우 반전 수정 2026-02-14 02:57:56 +09:00
f32b7ee615 feat: 일시정지 버튼을 눌렀을 때 별의 움직임이 함께 멈춤 2026-02-14 02:19:34 +09:00
6640962573 refactor: 항해일지 작성 모달로 변경 및 꾹 눌러서 종료 2026-02-14 02:05:43 +09:00
8e9ba0431b docs: cli 용 문서 업로드 (runbook: 개발, runbook_planner: 기획) 2026-02-14 02:05:07 +09:00
99c996b20e feat: 모달에서 enter를 눌렀을 때 다음화면으로 넘어가도록 실행 2026-02-13 15:52:44 +09:00
d60d4ccd9e refactor: fsd 구조로 변환 2026-02-13 15:20:35 +09:00
bb1a6fbdab feat: 홈 배경 별자리 및 비행 화면 배경 생성 2026-02-13 14:42:53 +09:00
35188c7b52 feat: 일지 작성 화면 2026-02-13 11:42:11 +09:00
751b34c39f feat: 세션 진입 전 목표 세팅 2026-02-13 11:37:18 +09:00
e9bd08c75d feat: 일지 작성화면 및 일지 화면 2026-02-13 11:36:44 +09:00
15c2100ba2 feat: 설정화면 생성 2026-02-13 11:36:21 +09:00
c37678ca01 feat: 메인화면 배경 생성 2026-02-13 11:36:05 +09:00
1fd357cf95 feat: 세션 진행화면 생성 2026-02-13 11:35:06 +09:00
73654788da chore: 커밋 누락 2026-02-12 22:59:17 +09:00
751c99ebe6 refactor: 이름 변경 2026-02-12 22:59:06 +09:00
9cde0e927a refactor: startAt을 저장하고 계산하여 시간을 표시하도록 수정 2026-02-10 14:58:24 +09:00
a0d551ae66 feat: next config "standalone" 추가 2026-02-09 15:35:21 +09:00
39e3c5392c refactor: suspense 로 페이지 감싸기 2026-02-09 15:29:36 +09:00
5c99cab332 refactor: suspense 로 페이지 감싸기 2026-02-09 15:27:00 +09:00
a7973705cf refactor: window storage 이벤트 제거 2026-02-09 15:19:39 +09:00
e98483d458 fix: 프로젝트 루트 context를 ..으로 설정 2026-02-09 15:17:23 +09:00
406ce69880 chore: 파일 명 수정 2026-02-09 06:13:12 +00:00
ee830e4b5e chore: 파일 명 수정 2026-02-09 15:10:34 +09:00
f683d6c17b feat: docker ci 설정 2026-02-09 14:57:01 +09:00
d3c5ad1f4d refactor: 흔들림 기능 삭제 2026-02-09 14:25:14 +09:00
b5dd39c6c2 feat: 세션 중, 끝났을 때 목표 표시 2026-02-05 12:35:35 +09:00
61cdba9822 feat: shadcn 라이브러리를 사용하여 모달 생성 2026-02-05 11:41:04 +09:00
d497348c01 refactor: 아직 기능이 없는 방 인원, 체크인 삭제 2026-02-02 14:04:32 +09:00
96 changed files with 8675 additions and 675 deletions

80
.cli/AGENTS.override.md Normal file
View File

@@ -0,0 +1,80 @@
# .cli/AGENTS.override.md (기획자/Planner)
## 역할
당신은 기획자(Planner)입니다. 목적은 “다음 작업 티켓(current)”을 **실행 가능한 수준**으로 작성하는 것입니다.
개발자는 이 티켓만 보고 레포에 직접 적용할 수 있어야 합니다.
## 허용 범위(절대 규칙)
- 수정/생성 가능: `.cli/**`
- 금지: 제품 코드 수정(`src/**`, `app/**`, `components/**` 등)
## 훌륭한 기획자의 기준(운영 원칙)
1. **사용자 문제를 정확히 정의한다**
- “무엇이 불편한가/왜 중요한가/언제 발생하는가”를 짧게 명확히 쓴다.
- 재현 단계(있으면) + 기대 동작을 분리한다.
2. **모호함을 줄이고, 판단을 기록한다**
- 불확실한 부분이 있으면 질문 1개만 하거나,
- 질문 없이 진행 가능하면 “가정(Assumption)”을 1~3줄로 명시한다.
3. **작게 쪼개고, 순서를 설계한다**
- 한 티켓 = 한 축(기능/리팩토링/디자인 변경을 섞지 않음)
- 범위가 크면 “토대→핵심→마감” 순으로 티켓을 쪼갠다.
4. **성공 기준(AC)을 검증 가능하게 만든다**
- AC는 체크 가능한 문장으로 3~7개 이내.
- “좋아 보이게” 같은 감상형 문장 금지.
- 회귀 방지용 AC(기존 동작 유지) 1~2개를 포함한다.
5. **범위를 좁혀 토큰/탐색 비용을 줄인다**
- 수정 대상 파일을 가능한 1~3개로 제한한다.
- 검색이 필요하면 1회 `rg`로 찾을 수 있게 키워드/경로 힌트를 제공한다.
6. **Non-scope로 일을 ‘하지 않게’ 만든다**
- 이번 티켓에서 하지 않을 것(금지)을 명시해 회귀/확장 욕구를 막는다.
- “리팩토링 금지/스타일 변경 금지/신규 기능 금지” 등 포함.
7. **리스크를 먼저 처리한다**
- 사용자 데이터 손상, 플로우 단절, 저장/타이머 핵심 로직 같은 리스크를 우선 점검한다.
- 필요한 경우 “호환 유지(딥링크/기존 데이터)”를 요구사항/AC에 명시한다.
8. **제품 톤/감정 설계를 훼손하지 않는다**
- 실패/미완료 같은 부정 표현 금지 정책을 존중한다(관련 작업일 때).
- 문구 변경이 필요하면 최소 범위로, 중립/다음 행동 중심으로 작성한다.
9. **작업 결과가 ‘다음 결정’을 돕게 한다**
- 가능하면 티켓에 “관측 포인트”를 1줄로 남긴다(예: 제출 성공률/이탈 구간 확인).
- 단, 신규 분석 기능 추가는 별도 티켓으로 분리한다.
## 산출물(필수)
- `@.cli/current.md`에 작업 티켓을 작성/갱신한다.
- current에는 반드시 포함:
- TASK_META
- 작업 목표
- 요구사항(번호)
- Non-scope
- 적용 파일(예상)
- AC(체크리스트)
- 완료 후 출력(최소)
## 참고 문서(읽기 전용)
- 구조/경계(FSD) 규칙은 `@.cli/docs/architecture.md`, `@.cli/docs/rules.md`를 참고한다.
(필요할 때만 최소 변경)
## 출력(최소)
- 변경된 파일 경로만 출력한다. (긴 설명/코드 덤프 금지)

1
.cli/current.md Normal file
View File

@@ -0,0 +1 @@
# .cli/current.md (empty)

89
.cli/docs/architecture.md Normal file
View File

@@ -0,0 +1,89 @@
# Architecture
## 목표
- 사람이 유지보수하기 쉬운 구조
- LLM/CLI가 토큰을 낭비하지 않고 정확히 수정할 수 있는 구조
- 경계(레이어)를 강제해 회귀(regression)를 예방
## 레이어 규칙 (FSD)
### app/\*
- 페이지/레이아웃은 **조합자(composer)** 역할만 한다
- `app/**`**widgets public API만** import 한다
- `app/**``features/entities/shared`의 UI를 직접 import 하지 않는다
### widgets/\*
- 페이지 단위 블록(화면 조립 단위)
- `index.ts`를 통해 public API를 노출한다
- 내부에서 `features/entities/shared`를 조합한다
### features/\*
- 제품 기능 단위(UI + model + lib)
- `app/*` 를 import 하면 안 된다
- `entities/shared` 는 import 가능
### entities/\*
- 도메인 모델(session/log 등)
- 가능한 한 타입/스키마를 스스로 소유한다
- `shared` 만 import 가능
### shared/\*
- 재사용 가능한 최소 단위(ui/lib/config/types)
- `entities/features/widgets/app` 를 import 하면 안 된다
## Public API 규칙
- 모든 widget은 `index.ts`를 가진다
- 페이지는 아래 형태로 import 한다:
- `import { X } from "@/widgets/x";`
- `app/*`에서 `widgets/x/ui/*` 같은 깊은 경로 import는 피한다
## 공용 위치 규칙
- `shared/ui`: 공용 UI 프리미티브(button/card/dialog/input…)
- `shared/lib`: 순수 유틸(math/motion 등)
- `shared/config`: 튜닝 상수(예: starfield params)
## LLM/CLI 작업 규칙
- 모든 작업은 `current.md`에 **대상 파일 경로**를 반드시 명시한다
- 튜닝 작업은 가능하면 **config만 수정**한다
- 레포 검색이 필요하면 `grep/rg`는 1회만 사용하고, 결과 요약은 5줄 이내로 제한한다
## Starfield 소유권
- Flight 배경 로직: `features/flight-starfield/**`
- Lobby 별자리/글린트: `features/lobby-starfield/**`
- 페이지 마운트: `widgets/flight-background`, `widgets/lobby-background`
- 튜닝 파라미터: `shared/config/starfield.ts`
## Boarding 소유권
- 탑승 시작 로직: `features/boarding/model/startVoyage.ts`
- 탑승 미션 폼 UI: `features/boarding/ui/BoardingMissionForm.tsx`
- 로비 출항 진입: `widgets/lobby-routes` (modal open/close + flight 진입)
- `/boarding` 라우트: 딥링크 호환을 위해 동일 form/model 재사용
- 메모/노트 입력은 탑승 생성 경로에서 제거됨
## I18n 소유권 (1단계)
- 지원 언어/기본값/카피 상수: `shared/config/i18n.ts`
- 초기 언어 결정/수동 고정 저장: `features/i18n/model/resolveInitialLocale.ts`
- 런타임 번역 접근(context/hook): `features/i18n/model/useI18n.tsx`
- 앱 초기 bootstrap + 수동 변경 UI(헤더 select): `features/i18n/ui/I18nLayoutShell.tsx`
- `app/layout.tsx`는 i18n shell을 마운트해 단일 URL에서 언어 상태만 관리한다
- 우선순위: `수동 저장값(localStorage) > 브라우저 언어 > en`
- 지원 외 언어는 `en`으로 폴백한다
- 페이지/UI 문구는 key(`lobby.*`, `flight.*`, `debrief.*`, `log.*`, `settings.*`, `routes.*`)로 관리한다
- 항로 메타(`shared/config/routes.ts`)는 사용자 노출 문자열 대신 i18n key를 소유한다
## 변경 정책
- 구조 리팩토링은 명시적으로 요청되지 않는 한 동작을 바꾸면 안 된다
- 포맷만 바꾸는 변경(diff) 지양

35
.cli/docs/rules.md Normal file
View File

@@ -0,0 +1,35 @@
# .cli/rules.md
## 범위 & 출력
- 변경 사항은 항상 **레포지토리에 직접 적용**한다. (diff만 출력하고 끝내기 금지)
- 출력은 최소로 유지한다:
1. 수정/생성/삭제된 파일 경로 목록
2. AC 체크리스트
3. housekeeping 결과 1줄
- 응답에 코드 전체를 덤프하지 않는다.
## 단일 진실(Source of Truth)
- `@.cli/current.md`는 요구사항과 AC의 **단일 진실**이다.
- current.md가 기존 문서/과거 지시와 충돌하면, **해당 실행(run)에서는 current.md를 우선**한다.
## FSD 경계 규칙
- `app/**``widgets`의 public API만 import 한다.
- `widgets/**``index.ts`를 통해 public API를 노출한다.
- `shared/**`는 안쪽 의존성이 없다(즉, `entities/features/widgets/app`를 import 하면 안 된다).
- 튜닝 파라미터는 가능하면 `shared/config`를 우선 사용한다.
## 검색 규율(토큰 절약)
- 필요하지 않으면 레포 전체를 스캔하지 않는다.
- 검색이 필요하면:
- `rg`는 1회만 실행한다
- 결과 요약은 5줄 이내로 제한한다 (내부 판단용; 장황한 로그 출력 금지)
## 안전 규칙
- current.md에 명시되지 않은 신규 기능을 추가하지 않는다.
- 요구되지 않으면 디자인/동작을 변경하지 않는다.
- 포맷만 바꾸는 변경(diff)은 피한다.

View File

@@ -0,0 +1,20 @@
# .cli/ops/archive_current.md
## 목적
- `.cli/current.md`의 전체 내용을 스냅샷으로 tasks 폴더에 저장한다.
- task 파일의 내용은 “이번 작업에 사용된 current.md 전체 내용”과 동일해야 한다.
## 입력
- `.cli/_task_context.md`에서 아래를 읽는다:
- TASK_ID
- TASK_SLUG
## 절차
1. `.cli/tasks/` 폴더가 없으면 생성한다.
2. 아카이브 파일 경로를 만든다:
- `.cli/tasks/{TASK_ID}-{TASK_SLUG}.md`
3. `.cli/current.md`의 전체 내용을 위 파일에 그대로 저장한다.
4. 저장된 파일이 존재하는지 확인한다.

40
.cli/ops/issue_task_id.md Normal file
View File

@@ -0,0 +1,40 @@
# .cli/ops/issue_task_id.md
## 목적
- 다음 TASK_ID를 발급하고,
- ops 간 공유 컨텍스트 파일 `.cli/_task_context.md`를 생성/갱신한다.
## 입력
- `.cli/current.md``TASK_META`에서 아래를 읽는다:
- TASK_TITLE
- TASK_SLUG
## 절차
1. `.cli/tasks/` 폴더가 없으면 생성한다.
2. `.cli/tasks/` 내 파일명 중 `^\d{4}-` 패턴(예: `0007-...`)을 가진 파일들을 찾는다.
3. 존재하면 가장 큰 4자리 번호를 TASK_ID_MAX로 두고, TASK_ID = TASK_ID_MAX + 1
4. 아무 파일도 없으면 TASK_ID = `0001`
5. TASK_ID는 4자리 zero-pad 유지(예: 0007)
## 날짜(DATE)
- DATE는 `YYYY-MM-DD` 형식으로 기록한다.
## 수정 파일 목록(FILES)
- 가능한 경우 `git status --porcelain`로 수정 파일 경로를 수집한다.
- git을 사용할 수 없으면, 최소한 이번 작업의 주요 파일을 나열한다(예: `src/app/page.tsx`).
- FILES는 쉼표로 구분된 문자열로 기록한다.
## 출력 파일(컨텍스트) — 고정 포맷
아래 형식으로 `.cli/_task_context.md`를 생성/덮어쓴다(포맷 변경 금지):
- TASK_ID: ####
- TASK_TITLE: ...
- TASK_SLUG: ...
- DATE: YYYY-MM-DD
- FILES: path1, path2

14
.cli/ops/reset_current.md Normal file
View File

@@ -0,0 +1,14 @@
# .cli/ops/reset_current.md
## 목적
- `.cli/current.md`를 다음 작업을 위해 비운다.
## 절차
- `.cli/current.md`의 내용을 아래 한 줄로 교체한다:
- `# .cli/current.md (empty)`
## 출력(없음)
- 별도 출력하지 않는다. (run_housekeeping이 최종 1줄 출력)

View File

@@ -0,0 +1,19 @@
# .cli/run_housekeeping.md
## 목적
작업 종료 후 정리 절차를 수행한다.
## 실행 순서(필수)
아래 문서들을 순서대로 수행하라:
1. `@.cli/ops/issue_task_id.md`
2. `@.cli/ops/archive_current.md`
3. `@.cli/ops/update_changelog.md`
4. `@.cli/ops/reset_current.md`
## 출력(최소)
마지막에 아래 1줄만 출력하라:
`HOUSEKEEPING | task_id=<####> | archived=OK | changelog=OK | current=reset`

View File

@@ -0,0 +1,50 @@
# .cli/ops/update_changelog.md
## 목적
- `.cli/changelog.md`에 이번 작업 항목을 “최신이 위”가 되도록 추가한다.
## 입력
- `.cli/_task_context.md`에서 아래를 읽는다:
- TASK_ID
- TASK_TITLE
- DATE
- FILES
## 준비
- `.cli/changelog.md`가 없으면 아래 템플릿으로 생성한다(그대로 복사).
### changelog 초기 템플릿(파일 생성 시)
# Changelog
> 규칙
>
> - 새 작업이 끝나면 맨 위(최신)에 추가한다.
> - 날짜 섹션이 없으면 새로 만든다.
> - 각 항목은 3~5줄 이내로 짧게.
> - “무엇이 바뀌었는지”와 “영향 범위(파일)”만 남긴다.
## YYYY-MM-DD
- [0001] 작업 제목(짧게)
- Summary: 변경 요약 1
- Summary: 변경 요약 2
- Files: path1, path2
## 추가 규칙
1. `## {DATE}` 섹션이 있으면, 그 섹션 **바로 아래 최상단**에 항목을 추가한다.
2. `## {DATE}` 섹션이 없으면, 파일 맨 위(헤더/규칙 블록 다음)에 새로 만들고 그 아래에 추가한다.
3. Summary는 2줄(최대 3줄)로 짧게 작성한다.
4. Files는 컨텍스트의 `FILES`를 그대로 기록한다.
## 추가 항목 템플릿(이 포맷 유지)
- [{TASK_ID}] {TASK_TITLE}
- Summary: (변경 요약 1줄)
- Summary: (변경 요약 1줄)
- Summary: (선택)
- Files: {FILES}

View File

@@ -0,0 +1,30 @@
# .cli/planner/compile_current.md
## 목적
`@.cli/planner/input.md`의 자연어 초안을 분석해, 실행 가능한 작업 티켓을 `@.cli/current.md`로 작성한다.
## 절차(필수)000
1. `@.cli/docs/architecture.md`, `@.cli/docs/rules.md`를 읽고 규칙을 준수한다.
2. `@.cli/input.md`를 읽고 아래를 추출한다:
- 문제(관찰)
- 목표(한 줄)
- 범위(수정 파일 1~3개 목표)
- 요구사항(번호 매김)
- Non-scope(금지 범위 명시)
- AC(체크리스트)
3. 모호한 부분이 있으면 **질문 1개만** 하거나,
질문 없이도 진행 가능하면 “합리적 가정”을 1~3줄로 current에 명시한다.
4. 결과물을 `@.cli/current.md`에 “작업 티켓 포맷(TASK_META 포함)”으로 저장한다.
5. `@.cli/input.md`는 그대로 둔다(지우지 않음).
## 제약
- Planner 역할: `.cli/**`만 수정 가능(제품 코드 수정 금지).
- 출력은 최소: 변경 파일 경로만.
## 산출물
- `@.cli/current.md` 가 변경되었으면 "current.md done" 한줄 출력

View File

@@ -0,0 +1,21 @@
# .cli/runbook_planner.md
## 목표
기획자(Planner)는 즉시 `@.cli/planner/compile_current.md` 절차를 수행하여
`@.cli/input.md`(자연어 초안) → `@.cli/current.md`(실행 가능한 작업 티켓)으로 컴파일한다.
## 실행 절차(필수)
1. 아래 문서를 참고(필요 시)한다:
- `@.cli/product/spec.md`
- `@.cli/docs/architecture.md`
- `@.cli/docs/rules.md`
2. **바로 다음 오퍼레이션을 수행한다(핵심)**
- `@.cli/input.md`를 읽고 `@.cli/planner/compile_current.md` 를 수행하여 `@.cli/current.md`를 작성/갱신한다.
## 제약
- Planner 역할: `.cli/**`만 수정 가능. 제품 코드(`src/**` 등) 수정 금지.
- 출력은 최소: 변경된 파일 경로만 출력.

282
.cli/product/spec.md Normal file
View File

@@ -0,0 +1,282 @@
# 포커스텔라(Focustella) Web MVP 기획문서 (PRD v0.1)
> 혼자 일하는 사람을 위한 “집중 항해” 서비스.
> 별자리 항로(시간)를 선택하고, 출항 전 미션(이번 작업 목표)을 싣고, 항해(집중) 후 도착(짧은 회고)으로 항해일지를 남긴다.
---
## 1. 목적과 성공 기준
### 1.1 문제 정의
- 혼자 일할 때 **집중 시작**이 어렵고, 흐트러지면 다시 **재시작**이 힘들다.
- 작업을 마친 뒤 “내가 뭘 했지?”가 남아 **성취감/회고**가 약하다.
- 목표 미달성 시 기록이 “실패”로 남으면 **기분이 나쁘고** 서비스 이탈로 이어질 수 있다.
### 1.2 Web MVP 목표
- 세계관(우주 항해)을 **방해가 아니라 동기/의식(ritual)**로 사용해
1. 출항(시작) 장벽을 낮추고
2. 항해(집중) 방해를 최소화하며
3. 도착(회고)로 다음 행동을 남긴다.
### 1.3 성공 지표(초기)
- Activation: 첫 방문→첫 항해 시작 전환율
- Completion: 항해 종료까지 도달률(중단률 포함)
- Retention: D1/D7 재항해율
- Journal: 항해일지 생성률, 회고 입력률(1줄이라도)
---
## 2. 핵심 컨셉
### 2.1 핵심 루프(Core Loop)
1. 로비에서 **항로 선택**
2. 출항 전 **미션 체크인(목표 한 줄)**
3. 항해(집중)
4. 도착(짧은 회고)
5. 항해일지 저장
6. 다음 항해로 연결
### 2.2 톤&매너
- “훈육/채찍” 금지. “관제/도킹/로그” 같은 **부드러운 안내**.
- 실패/낙제/미완료 같은 표현 최소화.
- 기록은 **성공/실패**가 아니라 **진행/학습/다음 행동** 중심.
---
## 3. MVP 범위
### 3.1 포함(Include)
- 데스크탑 웹 중심(모바일은 최소 대응)
- 첫 화면(로비)에서 항로 선택 → 미션 → 항해실 → 도착/일지
- 항로: 1시간/3시간(초기 2~6개 카드 이내)
- 항해일지: 항로/미션/결과 상태/한 줄 회고/다음 액션
- “출발 시각”은 **강제 스케줄이 아닌** *몰입을 돕는 선택지*로 설계
### 3.2 제외(Exclude) — MVP에서 하지 않기
- 실시간 채팅, 랭킹, 과한 게임화
- 복잡한 애니메이션(항해실에서 특히 금지)
- 강한 방해 차단(사이트 차단/앱 차단)
- 복잡한 소셜 매칭/파티 시스템
---
## 4. 출발 시각(강제성) 설계
### 4.1 원칙
- “정시 출발만 가능”처럼 **기다림 강제**는 이탈 위험이 큼.
- 강제성은 “시간표”보다 “절차/윈도우”가 세계관과 UX 모두에 유리.
### 4.2 MVP 추천: 혼합형
- 기본: **즉시 출항(차터)** — 마찰 최소
- 옵션: **출항 윈도우(게이트 개방 시간)** — 몰입/의식/약한 강제성
- 예: 하루 4~8회, 불규칙 시각(09:0809:15 같은 7분 창)
- 사용자는 “대기”가 아니라 “출항 준비”를 하도록 로비에 체크리스트 제공
- 글로벌: 윈도우는 사용자 **로컬 시간대 기준**으로 제공(개인 루틴 중심)
- (추후) 이벤트성 “UTC 글로벌 편”은 주 1회 정도로 실험 가능
### 4.3 놓쳤을 때 UX(중요)
- “실패”가 아니라 “다음 도킹 창 안내”
- 즉시 출항(차터)로 우회 가능하게 유지
---
## 5. 정보 구조(IA) & 화면 구성
### 5.1 라우팅(예시)
- `/` 로비(항로 선택)
- `/boarding` 미션 체크인(출항 직전)
- `/flight` 항해실(집중)
- `/debrief` 도착/회고
- `/log` 항해일지 목록
- `/log/:id` 항해일지 상세
- `/settings` 설정(초간소/모션 감소 등)
### 5.2 화면별 스펙
#### A) 로비(첫 화면) — “항로 선택이 주인공”
**목표:** 설명보다 선택→출항까지 1분 안에 도달
구성(최소):
- 상단: 로고 | 항해일지 | 설정
- 메인 문장: “어느 별자리로 출항할까요?”
- (선택) 미션 한 줄 입력(바로 입력해도 되고, 탑승 후 입력해도 됨)
- 항로 카드 그리드(최대 6개)
- 별자리명 / 소요시간 / 한 줄 용도(딥워크/정리 등) / `탑승`
- 출항 방식 표기: `바로 출항` 또는 `다음 게이트 09:08`
- 하단 분위기: “정거장에 N명이 대기 중”(실시간이 아니어도 됨)
카드 예시:
- 오리온 · 3h · 딥워크(집필/코딩)
- 거문고 · 1h · 기획/정리
- 백조 · 1h · 회고/리뷰
#### B) 탑승(미션 체크인)
**원칙:** 미션은 “결과”가 아니라 “행동/진척” 형태로 유도
필드:
- 미션(필수): 한 줄
- (선택) 메모: 1줄(오늘의 제약/컨디션)
- 출항 버튼: `출항(도킹 완료)`
가이드 문구(예):
- “완료보다 진척을 목표로 적어보세요.”
- “예: ‘서론 10문장 쓰기’, ‘가설 2개 검증하기’”
#### C) 항해실(집중 화면) — “초간소 UI”
**원칙:** 항해실에서 연출/깜빡임/복잡한 UI 금지
구성(최소):
- 상단 작은 라벨: `오리온 항로 · 순항`
- 중앙: 남은 시간(크게)
- 하단: `일시정지` `종료` `메모(선택)`
- 옵션: “초 단위 숨기기(분 단위만)”, “모션 감소”
단계(점화/접근 등)는 **화면 변화 없이**:
- 상단 얇은 토스트 3초(선택 행동 있을 때만)
- 예: “접근 단계: 마무리 5분 — 다음 액션 한 줄 적기”
#### D) 도착(회고)
**목표:** 30초~2분 내 회고 완료 → 일지 생성
질문(추천):
1. 이번 항해에서 **확보한 것**(한 줄)
2. 다음 항해의 **첫 행동**(한 줄)
3. (선택) 막힌 이유(칩 선택)
결과 상태(실패 금지, 3상태):
- ✅ 계획대로
- 🌓 부분 진행
- 🧭 방향 재설정(범위 조정/우선순위 변경)
CTA:
- `항해일지 저장`
- `다음 항로 선택`
#### E) 항해일지 목록/상세
- 목록: 카드형(날짜, 항로, 미션, 상태)
- 상세: 자동 생성 템플릿 + 사용자 회고
---
## 6. 데이터 모델(초안)
### 6.1 Entity: Route
- id
- name (e.g., "오리온")
- duration_minutes (60/180)
- tag (딥워크/정리/리뷰)
- description (1줄)
### 6.2 Entity: Voyage(항해 세션)
- id
- user_id (MVP에서는 익명 가능)
- route_id
- scheduled_type: `charter | window`
- scheduled_at (optional)
- started_at
- ended_at
- status: `completed | partial | reoriented | aborted`
- mission_text
- notes (optional)
- debrief_progress (한 줄)
- next_action (한 줄)
- blocker_tag(optional): `scope | interruption | energy | priority_change | other`
### 6.3 Entity: User(옵션)
- id
- timezone
- preferences: minimal_mode, hide_seconds, reduce_motion
---
## 7. UX 규칙(중요)
### 7.1 “목표 미달성” 감정 설계
- “실패/미완료”를 저장하지 않는다.
- 기록은 항상 “진행/확보/다음 액션”으로 남는다.
- 중단(aborted)도 “이탈”이 아니라 “복귀 안내 + 다음 액션” 중심으로 처리.
### 7.2 항해 중 방해 최소화
- 알림 최소(단계 토스트는 선택형/짧게)
- 애니메이션/깜빡임 금지(특히 항해실)
- 소리 기본 OFF(옵션)
---
## 8. 기술/구현 메모(비결정 영역)
- 프론트: (예) Next.js/React
- 상태/타이머: 탭 비활성/절전 대응 고려
- 저장: 로컬 우선(익명 MVP), 추후 로그인/동기화
---
## 9. 분석/이벤트(최소)
- `route_view` (로비 노출)
- `route_select` (항로 선택)
- `mission_submit` (미션 체크인 완료)
- `voyage_start`
- `voyage_pause`
- `voyage_end` (status 포함)
- `debrief_submit` (회고 입력 완료)
- `log_view`
---
## 10. 로드맵(후순위)
1. 계정/동기화/멀티 디바이스
2. 출항 윈도우 개인화(사용자 루틴 기반)
3. “이벤트성 UTC 글로벌 편”
4. 데스크탑 앱(트레이/항상 위/단축키)
5. 모바일: 알림/일지 보기 중심
---
## 11. 오픈 이슈(결정 필요)
- 항로 개수: MVP는 2~6개 중 최적?
- 출항 윈도우: 하루 몇 회가 이탈을 최소화하면서 몰입을 만들까?
- 익명 사용 vs 최소 로그인: 초기 전환율/재방문 간 trade-off
- 항해 중 중단 정책: 즉시 종료 허용 + 도착 회고 최소 강제 여부
---
## 12. Acceptance Criteria (MVP 완료 기준)
- 사용자는 첫 화면에서 3클릭 이내로 항해 시작 가능
- 항해실은 5개 이하 UI 요소로 유지(타이머/미션/버튼)
- 도착 시 3상태(완료/부분/재설정) 중 하나로 저장 가능
- 항해일지 목록에서 과거 항해를 열람 가능
- “실패/미완료”라는 표현 없이도 유저가 상황을 기록할 수 있음

82
.cli/runbook.md Normal file
View File

@@ -0,0 +1,82 @@
# .cli/runbook.md
## 목적
이 runbook은 CLI 에이전트가 **레포지토리에 변경을 직접 적용**할 때의 표준 절차를 정의한다.
모든 작업은 `@.cli/current.md`를 단일 진실(source of truth)로 삼는다.
또한, 아래 2개 문서를 **항상 존재**시키고 최신 상태로 유지한다.
- `@.cli/docs/architecture.md`
- `@.cli/docs/rules.md`
---
## 실행 절차
### 0) 기본 문서/규칙 확인(필수)
작업 시작 전 아래 파일이 없으면 생성한다(템플릿은 레포 내 최신본을 우선, 없으면 기본 템플릿 생성).
- `@.cli/docs/architecture.md`
- `@.cli/docs/rules.md`
> 이 단계는 기능 변경이 아니라 “운영 문서/규칙”의 존재 보장이다.
### 1) 작업 적용(필수)
- `@.cli/current.md`를 읽고, 요구사항/Non-scope/AC를 준수하여 **레포지토리에 직접 적용**한다.
- 범위가 명시된 경우(수정 파일 지정 등) 그 범위를 벗어나지 않는다.
- 불필요한 리팩토링/포맷 변경/스타일 변경은 하지 않는다.
### 2) 문서 동기화(필수)
- 이번 작업이 **구조(FSD), import 규칙, 폴더/엔트리 포인트**에 영향을 줬다면,
`docs/architecture.md`에 변경사항을 5~15줄 내로 반영한다.
- 이번 작업이 **CLI 운용 규칙**에 영향을 줬다면 `.cli/rules.md`를 반영한다.
- 단, 문서 업데이트는 “요약/원칙” 수준으로만 하고 장문 작성 금지.
### 3) 성공 시 하우스키핑(필수)
- 작업이 성공적으로 적용되면, 즉시 `@.cli/run_housekeeping.md`를 그대로 수행한다.
---
## 출력 규칙(최소 출력)
### 작업 적용 결과 출력(필수)
아래 3가지만 출력한다.
1. **수정/생성/삭제된 파일 경로 목록**
- 경로만, 짧게.
- 파일 내용 전체 출력 금지.
2. **AC 체크 결과**
- `- [ ]` 형식으로 current의 AC를 그대로 나열하고 `OK/NO`로 표기.
3. **docs/rules 상태(1줄)**
- `docs/rules: OK` 또는 `docs/rules: NO (<사유>)`
### 하우스키핑 결과 출력(1줄)
- `run_housekeeping: OK` 또는 실패 시 `run_housekeeping: NO (<사유 1줄>)`
---
## 절대 금지
- diff만 출력하고 적용하지 않기
- 코드 전체를 응답으로 덤프하기
- current 범위를 벗어난 수정(특히 Non-scope 위반)
- 임의의 신규 기능 추가
- 과도한 설명/장문의 로그 출력
---
## 작업 종료 후 정리(필수)
- 위 작업이 끝나면 `@.cli/ops/run_housekeeping.md` 절차를 그대로 수행하라.

View File

@@ -0,0 +1,59 @@
# .cli/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Lobby 카드 구성 단순화 + twinkle 강화
- TASK_SLUG: lobby-routes-cta-twinkle
## 작업 목표
`@src/app/page.tsx` 메인 로비 화면을 아래 요구사항대로 수정한다.
## 작업 요구사항
1. 별 반짝임(twinkle) 강화
- 메인화면에서 각 별자리(카드/배경에 표현된 별자리)의 별(점)들이 주기적으로 빛나는 느낌이 나도록 수정
- 별마다 반짝임 타이밍이 약간씩 다르게(랜덤 딜레이/듀레이션) 적용
- prefers-reduced-motion: reduce 환경에서는 반짝임을 끄거나 매우 약하게
2. 메인 구성 변경 (3개만)
- 메인 화면 카드/버튼 구성은 아래 3개만 존재:
- 우주정거장(무제한/무기한 대기 방): 1개 (CTA)
- 오리온: 60분 1개
- 쌍둥이자리: 30분 1개
- 기존에 있던 다른 별자리 항로/카드는 제거
3. 레이아웃 변경: 2-row 구성
- 버튼들의 부모 레이아웃을 2행(row 2개)으로 구성:
- 상단 row: 우주정거장(CTA) 버튼이 full-width로 단독 차지
- 하단 row: 오리온(60분), 쌍둥이자리(30분) 버튼이 2열로 나란히 배치
## Non-scope
- 하단 문구(“정거장 3명 대기 중”) 제거는 사용자가 직접 처리한다. 이 작업에서 변경하지 말 것.
- /flight 등 다른 페이지 로직/구조 변경 금지
- 신규 기능 추가 금지
- 과도한 번쩍임/강한 플래시/깜빡임 효과 금지
## 적용 파일
- 필수: `@src/app/page.tsx`
## 완료 조건(AC)
- [ ] 메인 화면에 카드/버튼이 3개만 보인다: 우주정거장(CTA), 오리온 60분, 쌍둥이자리 30분
- [ ] 레이아웃이 2행 구조로 동작한다(상단 CTA full / 하단 2열)
- [ ] 별들이 은은하게 반짝이며 별마다 타이밍이 달라 자연스럽다
- [ ] prefers-reduced-motion에서 반짝임이 정지 또는 매우 약해진다
## 완료 후 출력(최소)
- 수정된 파일 경로 목록
- AC 항목별 체크 결과(OK/NO)
## 작업 종료 후 정리(필수)
- 위 작업이 끝나면 `@.cli/run_housekeeping.md` 절차를 그대로 수행하라.

View File

@@ -0,0 +1,66 @@
# .cli/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Lobby 별자리 3개 고정 + 별 반짝임 구현 + 불필요 별/문구 제거
- TASK_SLUG: lobby-constellation-twinkle-fix
## 작업 목표
`@src/app/page.tsx` 로비 화면에서 아래 문제를 해결한다:
- “정거장 3명이 대기 중” 문구가 다시 나타나는 현상 제거(재발 방지)
- 배경 별자리는 **3개만 유지**(오리온/마차부/북두칠성)
- 각 별자리의 “별(점)”이 주기적으로 은은하게 반짝이도록(twinkle) **실제 구현**
- 별자리 외 **추가 별(랜덤 스타필드 등) 생성 금지** 및 기존에 생긴 불필요 별 제거
## 작업 요구사항
1. 문구 재발 방지(필수)
- 로비 화면에서 아래 문구가 렌더링되지 않도록 제거하고, 재도입되지 않게 한다:
- "정거장 3명 대기 중"
- "정거장 3명이 대기중"
- "대기 중" 관련 유사 문구(정거장 대기 인원 표현 포함)
- 주의: 하드코딩/데이터/컴포넌트 어디에 있든 **최종 UI에 나오면 실패**.
2. 배경 별자리 3개 고정(필수)
- 배경에는 아래 3개 별자리만 존재해야 한다(이름/레이블은 내부 코드에서만 사용해도 됨):
- 오리온
- 마차부(Auriga)
- 북두칠성(큰곰자리 일부 / Big Dipper)
- 기존에 있던 다른 별자리(거문고 등) 또는 별자리 외 요소는 제거한다.
3. 별 반짝임(twinkle) 실제 구현(필수)
- 반짝임은 **오리온/마차부/북두칠성의 별(점) 요소에만** 적용한다.
- “별자리 외 별(랜덤 점/스타필드)”에는 반짝임 적용 금지(애초에 생성 금지).
- 방식 권장:
- opacity 0.6~1.0 사이 미세 변화 + scale 0.98~1.03 미세 변화
- duration 2~6초 범위로 분산
- delay를 별마다 다르게(랜덤/고정 배열 모두 가능)
- `prefers-reduced-motion: reduce` 환경에서는 반짝임을 **정지 또는 매우 약화**.
4. 불필요 별(추가 생성 요소) 제거(필수)
- 별자리 3개 외의 별/점이 대량으로 추가되는 코드(랜덤 생성, 루프 생성, Canvas 스타필드 등)가 있다면 제거한다.
- 결과적으로 화면에 보이는 “별(점)”은 **3개 별자리를 구성하는 별(점)만**이어야 한다.
## Non-scope
- /flight 등 다른 페이지 로직/구조 변경 금지
- 신규 기능 추가 금지(로그인/채팅/윈도우 출항 등)
- 과도한 번쩍임/강한 플래시/깜빡임(고주파 점멸) 금지
## 적용 파일
- 필수: `@src/app/page.tsx`
- 필요 시(최소 변경): 공통 스타일/컴포넌트(별자리 SVG/스타 컴포넌트 분리하는 경우만)
## 완료 조건(AC)
- [ ] 로비 화면에서 “정거장 3명 대기 중/대기중” 및 정거장 대기 인원 문구가 **절대 표시되지 않는다**
- [ ] 배경 별자리는 **오리온/마차부/북두칠성 3개만** 존재한다
- [ ] 반짝임(twinkle)이 **각 별자리의 별(점)에 실제로 적용**되어 보인다(별마다 타이밍이 다름)
- [ ] 별자리 외 “추가 별(스타필드/랜덤 점)”이 **생성되지 않는다**

View File

@@ -0,0 +1,73 @@
# .cli/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Lobby twinkle를 “반짝임만”으로 고정(이동 제거) + 별자리 3개 유지
- TASK_SLUG: lobby-twinkle-only-no-move
## 작업 목표
`@src/app/page.tsx` 로비 배경에서 “반짝임(twinkle)”이 **이동 없이** 빛만 변하도록 수정한다.
또한 배경 별자리는 **오리온/마차부/북두칠성 3개만** 유지하고, 별자리 외 추가 별(스타필드)을 제거/금지한다.
## 작업 요구사항
1. 별자리 3개 고정(필수)
- 배경에는 아래 3개 별자리만 존재해야 한다:
- 오리온
- 마차부(Auriga)
- 북두칠성(Big Dipper)
- 다른 별자리(또는 별자리 외 장식 요소)가 있으면 제거한다.
2. “반짝임만” 구현(필수)
- 반짝임은 **별자리의 별(점) 요소에만** 적용한다.
- 반짝임 방식:
- `opacity` 변화만 허용(권장 범위: 0.6 ~ 1.0)
- (선택) `scale`은 0.99 ~ 1.01 이내에서만 허용(없어도 됨)
- 별마다 타이밍이 다르게:
- duration 2~6초 분산
- delay도 별마다 분산(랜덤 또는 고정 배열)
3. 이동/드리프트/패럴럭스 금지(최우선)
- 아래는 **전부 금지**이며, 존재하면 제거한다:
- translate / rotate / position 이동(left/top/x/y 변화)
- Canvas 스타필드/별 이동 애니메이션
- 배경 스크롤링/드리프트/패럴럭스(별이 흐르는 연출)
- 별(점)의 좌표는 시간에 따라 **절대 변하지 않아야 한다.**
4. 추가 별(스타필드) 금지(필수)
- 별자리 외의 랜덤 점/별을 생성하는 코드가 있으면 제거한다.
- 결과적으로 화면에 보이는 별(점)은 **3개 별자리를 구성하는 점들만**이어야 한다.
5. 모션 접근성(필수)
- `prefers-reduced-motion: reduce` 환경에서는 반짝임을 **정지**하거나 매우 약하게 만든다(정지 우선).
## Non-scope
- 로비의 버튼 구성/CTA/항로 구성 변경 금지(이번 작업은 배경 twinkle 품질/제약 정리 중심)
- /flight 등 다른 페이지 변경 금지
- 신규 기능 추가 금지
## 적용 파일
- 필수: `@src/app/page.tsx`
- 필요 시(최소 변경): 공통 스타일/컴포넌트 파일(별자리 SVG/스타 컴포넌트를 분리하는 경우만)
- (권장) 별자리 점 요소에는 식별 가능한 class/data-attribute를 부여해 twinkle 대상이 명확하도록 한다.
## 완료 조건(AC)
- [ ] 배경 별자리는 오리온/마차부/북두칠성 **3개만** 존재한다
- [ ] 별(점)은 **위치 이동이 전혀 없고**, 빛(opacity)만 변한다(반짝임만)
- [ ] translate/rotate/드리프트/패럴럭스/스타필드(Canvas 포함) 코드가 존재하지 않는다
- [ ] 별자리 외 “추가 별(랜덤 점/스타필드)”이 생성되지 않는다
- [ ] prefers-reduced-motion에서 반짝임이 정지(또는 매우 약화)된다
## 완료 후 출력(최소)
- 수정된 파일 경로 목록
- AC 항목별 체크 결과(OK/NO)

View File

@@ -0,0 +1,90 @@
# .cli/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Lobby 별 반짝임을 “빛 번짐(십자/팔각 글린트)”으로 구현(이동 없음)
- TASK_SLUG: lobby-glint-twinkle-opacity-only
## 작업 목표
`@src/app/page.tsx` 로비 배경의 별자리(오리온/마차부/북두칠성)에서,
별(점)이 단순 점멸이 아니라 **십자(4-point) 또는 팔각(8-point) 형태의 빛 번짐(glint)**으로 반짝이도록 개선한다.
단, **어떠한 이동/드리프트/패럴럭스도 발생하면 안 된다.**
## 전제(현재 상태)
- 배경 별자리는 3개(오리온/마차부/북두칠성)만 유지한다.
- 기존에 랜덤 별(스타필드) 생성 코드가 있다면 이번 작업에서 제거 대상이다(별자리 외 별 0).
## 작업 요구사항
1. 글린트(빛 번짐) 형태 구현(필수)
- 각 별은 아래 구성 요소로 표현한다(권장: SVG):
- 핵심 별: `circle`
- 글린트: `line` 2개(십자) 또는 4개(팔각 = 십자+대각선)
- 글린트는 “항상 보이는 선”이 아니라, 반짝일 때만 **opacity가 올라왔다 내려가야** 한다.
- 핵심 별(circle)도 약한 opacity 변화는 가능하나, 주역은 글린트가 되어야 한다.
2. 애니메이션은 opacity만(최우선)
- 아래는 전부 금지(존재하면 제거):
- `transform` 전부(translate/scale/rotate 포함)
- `transition-transform`
- position 이동(left/top/x/y 변화)
- Canvas 스타필드 / requestAnimationFrame 기반 이동
- 배경 드리프트/패럴럭스/스크롤링
- 반짝임 애니메이션은 **opacity만** 변화하도록 구현한다.
- (선택) circle의 opacity는 0.65~1.0 범위, 글린트는 0~0.85 범위 권장.
3. 별별 랜덤 타이밍 분산(필수)
- 별마다 애니메이션 타이밍이 약간씩 다르게 보이도록:
- duration: 2~6초 범위 분산
- delay: 별마다 분산
- 랜덤은 “렌더 시 고정”이어야 한다(매 프레임 바뀌는 랜덤 금지).
4. 모션 접근성(필수)
- `prefers-reduced-motion: reduce` 환경에서는:
- 애니메이션을 끄거나(정지 우선) 아주 약하게
- 글린트는 opacity를 매우 낮게 고정(예: 0.1~0.2)
5. 별자리 3개 고정 & 추가 별 금지(필수)
- 배경 별자리는 오리온/마차부/북두칠성 3개만 존재해야 한다.
- 별자리 외 추가 별(랜덤 점/스타필드)은 생성되지 않아야 한다.
## 구현 가이드(권장 방향)
- `page.tsx` 내 별자리 SVG 렌더링에서 “Star” 컴포넌트를 만들고,
별 좌표(cx,cy)마다 `circle + glint lines`를 렌더링한다.
- CSS는 Tailwind `@layer utilities` 또는 모듈 CSS로 작성하되,
keyframes에는 **opacity만** 포함한다.
- 글린트 선은 `strokeLinecap="round"`를 사용하고 너무 굵지 않게(0.6~1.0) 유지한다.
- 필요하다면 `feGaussianBlur` 기반의 아주 약한 glow 필터를 추가할 수 있으나,
성능/가독성에 영향이 없도록 최소화한다(선택).
## Non-scope
- 로비의 CTA/항로 카드 구성 변경 금지(이번 작업은 배경 별 반짝임 연출 품질 개선)
- /flight 등 다른 페이지 변경 금지
- 신규 기능 추가 금지
## 적용 파일
- 필수: `@src/app/page.tsx`
- 필요 시(최소 변경): 글로벌 CSS(유틸리티 keyframes 추가가 필요한 경우만)
## 완료 조건(AC)
- [ ] 글린트(십자/팔각) 형태의 빛 번짐이 별(점)에서 실제로 보인다
- [ ] 별자리 관련 어떤 요소에도 transform/translate/scale/rotate가 적용되지 않는다(opactiy only)
- [ ] 별자리 외 랜덤 별(스타필드)이 생성되지 않는다(추가 별 0)
- [ ] 별마다 반짝임 타이밍이 다르게 느껴진다(duration/delay 분산)
- [ ] prefers-reduced-motion에서 애니메이션이 정지 또는 매우 약화된다
## 완료 후 출력(최소)
- 수정된 파일 경로 목록
- AC 항목별 체크 결과(OK/NO)

View File

@@ -0,0 +1,82 @@
# .gemini/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Lobby 별 반짝임을 “십자 글린트 + 블룸(이미지 참고)”로 개선(이동/transform 0)
- TASK_SLUG: lobby-glint-cross-bloom-like-ref
## 작업 목표
`@src/app/page.tsx` 로비 배경의 별자리(오리온/마차부/북두칠성) 별(점)을
첨부 이미지처럼 “십자(4-point) 글린트 + 부드러운 빛 번짐(bloom)” 느낌으로 반짝이게 만든다.
## 핵심 제약(최우선)
- 애니메이션은 **opacity만** 허용한다.
- `transform`(scale/translate/rotate 포함) 사용 **절대 금지**.
- 대각선(×) 글린트 금지. **십자(+)만** 사용.
- 별자리 외 랜덤 별/스타필드/Canvas 이동 연출 금지.
## 작업 요구사항
1. 별자리 3개 고정(필수)
- 배경에는 오리온/마차부/북두칠성 3개만 존재.
- 별자리 외 추가 별(랜덤 점/스타필드)은 0.
2. “이미지 같은” 글린트 형태(필수, SVG 권장)
각 별은 아래 구조로 표현한다:
- Core: 작은 `circle` (중앙 빛)
- Spikes: 수평 line 1개 + 수직 line 1개 (십자)
- Spikes는 “끝이 뚝 끊기지 않고” **양 끝으로 갈수록 서서히 투명해져야 함**:
- `stroke`를 단색이 아니라 `linearGradient`로 처리(중앙 흰색, 양끝 투명)
- Bloom: core/스파이크에 **아주 약한 blur glow**를 1겹만 추가
- 방법: (권장) 스파이크를 2번 그리기
- 1. blur+낮은 opacity(블룸용)
- 2. 선명한 라인(본선)
- 또는 SVG filter(feGaussianBlur) 1개를 최소로 사용
3. 반짝임 리듬(필수)
- 글린트는 항상 켜져 있지 않고, 대부분은 거의 안 보이다가 잠깐 피크:
- 기본 opacity: 0 (또는 0.02~0.05)
- 피크 순간만 0.7~0.9로 올라왔다가 빠르게 내려옴
- 별마다 duration(2~6s), delay 분산(고정 랜덤 OK, 프레임 랜덤 금지)
4. 디자인 수치(권장 고정값)
- StrokeWidth: 0.6 전후(0.5~0.8)
- Arm 길이(한쪽): 5px(작은 별) ~ 10px(밝은 별) 정도
- Core 반지름: 1.2 ~ 2.0
- Bloom(블러 라인) opacity는 본선보다 훨씬 낮게(예: 본선 1.0이면 블룸 0.25 이하)
5. 모션 접근성(필수)
- prefers-reduced-motion: reduce 에서는 애니메이션 OFF(정지 우선)
- 글린트는 opacity를 낮게 고정(예: 0.1) 또는 0
## Non-scope
- 로비 CTA/항로 카드 구성 변경 금지
- /flight 등 다른 페이지 변경 금지
- 신규 기능 추가 금지
## 적용 파일
- 필수: `@src/app/page.tsx`
- 필요 시(최소): 글로벌 CSS에 twinkle keyframes/클래스 추가
## 완료 조건(AC)
- [ ] 글린트는 십자(+)만 존재하고 대각선(×)이 없다
- [ ] 스파이크가 그라데이션으로 자연스럽게 사라져(끝이 부드러움) 이미지와 유사한 느낌이다
- [ ] bloom이 과하지 않고 “은은한 번짐” 정도로만 보인다
- [ ] 애니메이션은 opacity만 사용하며 transform이 0이다(지터/이동 없음)
- [ ] 별자리 3개만 유지되고 추가 별/스타필드가 없다
- [ ] prefers-reduced-motion에서 애니메이션이 정지(또는 매우 약화)된다
## 완료 후 출력(최소)
- 수정된 파일 경로 목록
- AC 항목별 체크 결과(OK/NO)

View File

@@ -0,0 +1,106 @@
# .gemini/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Lobby 별(노드) UI를 “빛나는 별”로 개선(그라데이션 스파이크 + 블룸 + 강약) — 이동 없음
- TASK_SLUG: lobby-star-glint-gradient-bloom-fix
## 작업 목표
현재 로비 배경 별자리 결과물이 “UI 노드/플러스 아이콘”처럼 보이는 문제를 해결하고,
레퍼런스처럼 **중앙이 밝고, 십자 스파이크가 끝으로 갈수록 사라지는** “빛나는 별” 느낌으로 개선한다.
추가로 별마다 강약을 줘서 도식 느낌을 줄인다.
## 핵심 제약(최우선)
- 애니메이션은 **opacity만** 허용한다.
- `transform`(scale/translate/rotate 포함) 사용 절대 금지.
- 십자(+)만 사용(대각선 × 금지).
- 별자리 외 랜덤 별/스타필드/Canvas 이동 연출 금지.
- 별(점) 위치는 절대 변하지 않아야 한다.
## 개선 포인트(반드시 반영)
1. 스파이크가 “딱 끊기는 선”처럼 보임 → **그라데이션 스트로크**로 끝이 서서히 사라지게
2. 빛 번짐이 없음 → **블룸(1겹)** 추가(과하지 않게)
3. 중심 원이 버튼/노드처럼 큼 → **코어는 더 작게**, 대신 은은한 글로우/하이라이트
4. 전체가 균일해 도식 느낌 → 별마다 **강약(크기/스파이크 길이/피크 빈도)** 분산
## 작업 요구사항
### 1) 스파이크 그라데이션(필수)
- 각 별의 십자 스파이크(수평/수직)는 단색 stroke가 아니라 다음 형태로 구현:
- 중앙(코어 근처)은 밝게
- 양 끝으로 갈수록 투명(0)으로 페이드아웃
- 구현 방식(권장): SVG `linearGradient`
- 수평: x1=0% → x2=100% (중앙이 밝고 양끝 투명)
- 수직: y1=0% → y2=100% (중앙이 밝고 양끝 투명)
### 2) 블룸(빛 번짐) 1겹 추가(필수)
- 스파이크를 2겹으로 그린다:
1. **블룸 레이어**: 더 굵고(strokeWidth +0.6~1.2), opacity 낮게(<= 0.25), 약한 blur(가능하면)
2. **본선 레이어**: 얇고 선명(strokeWidth 0.5~0.8), opacity는 피크에서만 올라가게
- blur는 성능을 위해 최소:
- 가능하면 SVG filter(feGaussianBlur) 1개만 사용하거나,
- filter 없이도 “굵고 투명한 1겹”만으로 블룸 느낌을 만들 수 있음(우선 이쪽 권장)
### 3) 코어(중심 원) 개선(필수)
- 중심 원을 “큰 단색 원”에서 “작은 밝은 코어 + 은은한 글로우”로 변경:
- 코어 circle r: 1.2~1.8
- 글로우 circle(선택): r을 더 크게(2.8~4.0) 하고 opacity 매우 낮게(<= 0.12)
- 코어는 항상 보이되, 살짝만(0.8~1.0) opacity 변화는 허용
### 4) 반짝임 리듬(필수, opacity only)
- 스파이크는 기본 opacity 거의 0(또는 0.02~0.05)로 유지하다가,
**짧게 피크를 찍는 형태**로 반짝이게 한다.
- 권장 keyframe(예시 개념):
- 0%, 35%, 100%: 0
- 50%: 0.85
- 60%: 0.1
- 별마다 duration(2~6초), delay 분산(렌더 시 고정)
### 5) 별별 강약 분산(필수)
- 모든 별이 같은 크기/스파이크 길이면 도식처럼 보이므로,
별마다 아래 중 2개 이상을 분산 적용:
- 코어 r (예: 1.2~1.9)
- 스파이크 arm 길이(예: 5~11)
- 스파이크 피크 opacity(예: 0.6~0.9)
- duration/delay
- 단, 과하게 다양하면 산만하니 “2~3단계 티어”로만 나눠도 됨(작은 별/중간 별/밝은 별)
### 6) 모션 접근성(필수)
- prefers-reduced-motion: reduce 에서는:
- 애니메이션 OFF(정지 우선)
- 스파이크는 아주 낮은 opacity(<= 0.08) 또는 0
## Non-scope
- 로비 CTA/항로 카드 구성 변경 금지
- /flight 등 다른 페이지 변경 금지
- 신규 기능 추가 금지
## 적용 파일
- 필수: `@src/app/page.tsx`
- 필요 시(최소): 글로벌 CSS에 keyframes/유틸리티 클래스 추가
## 완료 조건(AC)
- [ ] 스파이크가 그라데이션으로 자연스럽게 사라져 “플러스 아이콘” 느낌이 줄었다
- [ ] 블룸(은은한 번짐)으로 ‘빛’ 느낌이 생겼다(과하지 않음)
- [ ] 코어가 버튼처럼 크지 않고, 작은 밝은 점 + 은은한 글로우로 보인다
- [ ] 별마다 강약이 달라 도식 느낌이 줄었다
- [ ] 애니메이션은 opacity만 사용하며 transform이 0이다(이동/지터 없음)
- [ ] 별자리 외 추가 별/스타필드가 없다
- [ ] prefers-reduced-motion에서 애니메이션이 정지(또는 매우 약화)된다
## 완료 후 출력(최소)
- 수정된 파일 경로 목록
- AC 항목별 체크 결과(OK/NO)

View File

@@ -0,0 +1,87 @@
# .gemini/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Flight 스타필드 방향 수정(중심→4모서리 대각) + 중심부 자연화 + 별 개수 축소
- TASK_SLUG: flight-starfield-diagonal-from-center
## 현재 문제(관찰)
- 현재 flight 배경 별들이 “위→아래”처럼 흐르는 느낌이 강함(좌상→좌하, 우상→우하).
- 실제 유영/전진 느낌은 “중심(시선)에서 바깥으로 퍼져나가며 모서리로 흘러가는” 형태가 필요.
- 중심을 점 1개로 두면 어색하고 인공적으로 보임.
- 별(스트릭) 개수가 너무 많아 산만함.
## 작업 목표
`@src/app/flight/page.tsx`의 배경 스타필드를 다음처럼 변경한다:
1. 별 이동 방향을 **중심부 → 화면 4개 모서리(대각선) 방향**으로 재구성한다.
2. 중심부(소실점)가 어색하지 않도록 “영역”으로 자연스럽게 처리한다.
3. 별(스트릭) 수를 줄여 과밀/산만함을 해소한다.
4. 타이머/버튼 등 기존 UI는 그대로 유지하고 방해하지 않는다.
## 작업 요구사항
### 1) 이동 방향(필수)
- 별은 화면 중심(또는 중심 근처 영역)에서 생성/등장한 뒤,
**NE / NW / SE / SW 네 방향(모서리 대각선)**으로 흘러가야 한다.
- “위에서 아래로 떨어지는” 인상(수직 낙하/편향)은 금지.
- 구현 가이드(권장):
- 각 별에 quadrant(NE/NW/SE/SW) 할당
- 중심 벡터(해당 모서리 방향) + 작은 각도 노이즈(±5~15도)로 자연스러운 퍼짐
- 속도는 랜덤 티어(느림/중간/빠름)로 분산
### 2) 중심부 처리(필수)
- 중심이 “점 1개”처럼 보이지 않게 해야 한다.
- 구현 방식 중 택1(권장 순):
1. 별 생성 위치를 중심 ‘반경 r(예: 20~60px) 안에서 랜덤 분포(중심 영역)
2. 중심부에 아주 약한 그라데이션/베일(예: 작은 타원형 glow)을 깔아 소실점을 완화
- 중심부가 과하게 밝아져 UI를 방해하면 안 됨(은은하게).
### 3) 별(스트릭) 밀도/개수 축소(필수)
- 별이 “너무 많다” 문제 해결:
- 기본 별 개수를 현재 대비 **명확히 감소** (권장: 40~90개 범위)
- 동시에 화면 전체가 텅 비지 않게 “티어+리사이클”로 유지
- 별 크기/밝기/길이도 분산하되 과한 번쩍임 금지.
### 4) 성능 및 구현 방식(권장)
- Canvas 사용 중이면 Canvas 유지(권장). DOM 요소 다량 생성은 지양.
- requestAnimationFrame 루프는 유지하되,
- 불필요한 재생성/과도한 오브젝트 수 금지
- 리사이클(화면 밖으로 나가면 중심 근처로 재스폰)
- 배경 레이어는 `pointer-events: none` 유지.
### 5) 모션 접근성(필수)
- `prefers-reduced-motion: reduce`에서는:
- 별 이동을 정지하거나(정지 우선) 속도를 크게 낮춘다.
- 배경이 정지해도 UI 사용에 문제 없게.
## Non-scope
- 타이머 로직/버튼 동작/항해실 UI 요소 추가 금지(기능 유지).
- 다른 페이지 수정 금지(필요 시 공통 스타일만 최소 변경 허용).
- 신규 기능 추가 금지.
## 적용 파일
- 필수: `@src/app/flight/page.tsx`
- (허용) 필요 시 최소한의 스타일 파일(모션 접근성/배경 레이어 관련)
## 완료 조건(AC)
- [ ] 별 흐름이 “중심부 → 4모서리 대각선 방향”으로 명확히 보인다(수직 낙하 인상 없음)
- [ ] 중심부가 점 1개처럼 어색하지 않고 자연스럽다(영역/베일 처리)
- [ ] 별(스트릭) 수가 눈에 띄게 줄어 산만함이 감소했다
- [ ] UI(타이머/버튼/라벨) 가독성/클릭이 방해받지 않는다(pointer-events none)
- [ ] prefers-reduced-motion에서 이동이 정지 또는 크게 약화된다
## 완료 후 출력(최소)
- 수정된 파일 경로 목록
- AC 항목별 체크 결과(OK/NO)

View File

@@ -0,0 +1,97 @@
# .gemini/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Flight 스타필드(이미지 없이) — “조용한 우주” 점 중심 + 약한 전진감 + 교차 0 (저밀도 튜닝)
- TASK_SLUG: flight-starfield-calm-forward-low-density
## 현재 문제(관찰)
- 별이 길게 늘어난 스트릭이 많아 산만하고, 교차가 생기면 X자 워프처럼 보인다.
- 중앙(타이머) 주변까지 별이 과하게 지나가면 UI 몰입이 깨진다.
- 별이 많아 보이는(과밀) 인상이 강하다.
## 작업 목표
`@src/app/flight/page.tsx` 배경 스타필드를 “조용한 우주” 스타일로 재구성한다:
1. 대부분은 **정적인 점**에 가깝고,
2. **아주 약한 전진감(같은 방향의 미세 이동)**만 느껴지며,
3. 별(선) 교차/X자 인상이 **절대** 생기지 않도록 한다.
4. 별 개수를 “저밀도”로 튜닝해 산만함을 줄인다.
5. UI는 변경하지 않는다(배경만).
## 핵심 스타일 규격(이미지 없이 구현 가능한 스펙)
### A. 방향/움직임 (교차 0이 최우선)
- 별 이동은 **하나의 주 방향 벡터(vBase)**만 사용한다.
- 예: (dx=+0.15, dy=+1.0) 처럼 “아주 약한 대각 + 아래” 한 방향
- 모든 별은 vBase의 **x 부호가 동일**해야 한다(좌/우 반대 섞기 금지)
- 별마다 각도 노이즈는 아주 작게(±3~6도)만 허용.
- 속도는 “느림 위주”:
- 85~95%: 느림
- 5~15%: 중간
- 빠른 레이어 금지
### B. 별 모양(점 중심)
- 90~97%는 “점” 또는 “매우 짧은 꼬리(2~5px)”만
- 3~10%만 “중간 꼬리(6~14px)” 허용
- 매우 긴 꼬리(> 16px) 금지
- 밝기는 절제(대부분 낮게, 소수만 약간 밝게)
### C. 밀도(저밀도) — 이번 작업의 핵심
- 별 개수는 기존보다 확실히 줄인다.
- 권장 개수(고정값 상한):
- 데스크탑: **18 ~ 45**
- 모바일: **12 ~ 30**
- 가능하면 개수를 화면 면적에 따라 스케일하되, 위 상한을 넘지 말 것:
- `count = clamp(round((width*height)/K), min, max)`
- K는 “너무 많아 보이지 않는” 수준으로 크게(예: 30,000~60,000 사이에서 조정)
- 중앙 UI 보호:
- 타이머 중심 기준 반경 R(예: 150~230px) 영역은 스폰 확률↓ 또는 밝기↓
### D. 재스폰/리사이클(일관된 흐름 유지)
- 별이 화면 밖으로 나가면 재스폰.
- 재스폰 위치는 흐름 시작 가장자리에서 생성(방향 일관 유지).
- 재스폰 후에도 방향은 동일(반대 방향 튀기 금지).
### E. 기술/성능(권장)
- Canvas 기반이면 Canvas 유지 권장.
- requestAnimationFrame 유지.
- 배경 레이어 `pointer-events: none` 유지.
- 과도한 blur/고비용 필터 금지.
### F. 모션 접근성(필수)
- prefers-reduced-motion: reduce:
- 이동 정지(우선) 또는 속도 10~20%로 감속
## Non-scope
- 타이머/버튼/미션 등 UI 레이아웃 변경 금지(배경만).
- 타이머 로직/상태 저장 변경 금지.
- 신규 기능 추가 금지.
- 다른 페이지 변경 금지.
## 적용 파일
- 필수: `@src/app/flight/page.tsx`
## 완료 조건(AC)
- [ ] 별 이동이 “한 방향 전진감”으로만 보이며 X자 교차 인상이 없다(좌/우 반대 혼합 0)
- [ ] 별의 대부분이 점(또는 매우 짧은 꼬리)로 보이고, 긴 스트릭이 거의 없다
- [ ] 별 개수가 데스크탑 18~45 / 모바일 12~30 범위로 유지되어 산만함이 감소했다
- [ ] 중앙(타이머) 가독성이 유지된다(중앙 보호 규칙 적용)
- [ ] UI는 변경되지 않았다
- [ ] prefers-reduced-motion에서 정지 또는 크게 약화된다
## 완료 후 출력(최소)
- 수정된 파일 경로 목록
- AC 항목별 체크 결과(OK/NO)

View File

@@ -0,0 +1,107 @@
# .gemini/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Flight 스타필드 가시성/전진감 개선 — “먼지 낙하” 제거 + 원근 전진(소실점) + 저밀도 유지
- TASK_SLUG: flight-starfield-forward-visible-no-dust
## 현재 문제(관찰)
- “한 방향 전진감”을 적용했더니 별이 위→아래로 떨어지는 먼지처럼 보인다.
- 별이 너무 희미하고 꼬리가 짧아 거의 보이지 않는다.
## 작업 목표
`@src/app/flight/page.tsx` 배경 스타필드를 다음처럼 조정한다:
1. 낙하(먼지) 느낌을 제거하고 **원근 기반 전진감(소실점/투영)**으로 보이게 한다.
2. 저밀도(산만하지 않게)는 유지하되, 별이 **확실히 ‘보일 정도’로 가시성**을 올린다.
3. X자 교차/워프 느낌은 만들지 않는다(과한 방사형/대칭 금지).
4. UI는 변경하지 않는다(배경만).
## 핵심 구현 방향(권장: 3D 투영 스타필드)
- 별을 (x,y,z)로 관리하고 z를 프레임마다 감소시키며 투영:
- 소실점(vp)은 화면 정중앙보다 **살짝 위**(예: centerY - 40~90px)
- 투영: sx = vpX + (x - vpX) / z, sy = vpY + (y - vpY) / z
- z가 작아질수록 별이 바깥으로 “퍼져 나가며” 전진감이 생김
- “먼지 낙하”가 생기는 vBase 수직 이동 방식은 제거하거나, 투영 방식으로 전환한다.
## 스타일 규격(이미지 없이 구현 가능)
### A) 가시성(필수: ‘안 보임’ 해결)
- 별 밝기(alpha) 최소치를 둔다:
- 일반 별: alpha 0.25 ~ 0.55
- 강조 별(10~20%): alpha 0.55 ~ 0.85
- 별 크기:
- 일반: radius 0.7 ~ 1.2
- 강조: radius 1.2 ~ 1.8
- 색상은 흰색 계열 유지(과한 색 금지)
### B) 속도/전진감(필수)
- 너무 느리면 먼지/정지처럼 보임 → 최소 속도 확보:
- z 감소량(또는 speed)은 티어로:
- 느림(70~85%): 0.006 ~ 0.012
- 중간(15~30%): 0.012 ~ 0.022
- 빠름(0~5%): 0.022 ~ 0.030 (과하면 워프 느낌, 최소로)
- “한 방향”은 수직 낙하가 아니라 **소실점 기반 투영**으로 만든다.
### C) 꼬리(스트릭)는 ‘소수만’ + 길이는 보일 정도로(필수)
- 별의 75~90%는 점(꼬리 없음 또는 1~3px)
- 10~25%만 짧은 꼬리:
- 길이: 4~12px (현재 “안 보임” 이슈 해결을 위해 하한 4px)
- 긴 꼬리(> 16px) 금지
- 꼬리는 별 이동 벡터의 반대 방향으로 그린다(선형)
### D) X자/과한 방사형(워프) 방지(필수)
- 소실점(vp)을 정중앙에 두지 말고 **살짝 오프셋**(위로 + 약간 좌/우 10~30px 랜덤 고정)하여 완벽한 대칭을 깨라.
- FOV(퍼짐 정도)가 과하면 워프처럼 보임 → z 범위와 투영 스케일을 조정해 “부드럽게” 퍼지도록.
- 별 개수는 적게 유지해 과한 선 교차 인상을 줄인다.
### E) 밀도(저밀도 유지)
- 데스크탑: 18 ~ 45
- 모바일: 12 ~ 30
- 중앙 UI 보호:
- 타이머 중심 반경 R(예: 150~220px)에서는
- 별 스폰 확률↓ 또는 alpha 하향(완전 공백 금지)
### F) 리사이클(필수)
- 별이 화면 밖으로 나가거나 z가 임계치 이하가 되면:
- z를 큰 값으로 재설정하고,
- x,y는 소실점 주변 영역에서 재스폰(반경 30~120px) → “중심에서 시작” 느낌 유지
### G) 모션 접근성(필수)
- prefers-reduced-motion: reduce:
- 애니메이션 정지(우선) 또는 speed 10~20%로 감속
## Non-scope
- UI(A안) 변경 금지
- 타이머 로직 변경 금지
- 신규 기능 추가 금지
- 다른 페이지 변경 금지
## 적용 파일
- 필수: `@src/app/flight/page.tsx`
## 완료 조건(AC)
- [ ] “먼지 낙하(위→아래)” 인상이 사라지고, 원근 전진감(소실점 퍼짐)으로 보인다
- [ ] 별이 ‘확실히 보이는’ 밝기/크기/꼬리(하한 포함)로 개선되었다
- [ ] 과한 대칭 방사형(워프/X자) 인상이 없다(오프셋/스케일로 완화)
- [ ] 저밀도(데스크탑 18~45 / 모바일 12~30)가 유지되어 산만하지 않다
- [ ] UI는 변경되지 않았다
- [ ] prefers-reduced-motion에서 정지 또는 크게 약화된다
## 완료 후 출력(최소)
- 수정된 파일 경로 목록
- AC 항목별 체크 결과(OK/NO)

View File

@@ -0,0 +1,92 @@
# .gemini/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Flight 스타필드 튜닝 — 중심 과집중 완화(넓은 스폰) + 속도 감속(유영 느낌)
- TASK_SLUG: flight-starfield-spawn-widen-speed-down
## 현재 문제(관찰)
- 별이 중심점(소실점)에서만 생성되는 느낌이 강해 인공적이다.
- 별 이동 속도가 너무 빠르게 느껴져 “유영”이 아니라 “워프/가속”처럼 보인다.
## 작업 목표
`@src/app/flight/page.tsx` 배경 스타필드를 아래처럼 튜닝한다:
1. 별 생성 분포를 넓혀 “중심에서만 뿜는 느낌”을 완화한다(자연스럽게).
2. 전체 속도를 낮춰 “조용한 전진/유영” 느낌으로 만든다.
3. 가시성(안 보임 문제)은 유지한다(너무 희미해지지 않게).
4. UI는 변경하지 않는다(배경만).
## 작업 요구사항
### 1) 스폰 분포 넓히기(필수)
- 별의 초기 (x,y) 분포를 “소실점 주변 아주 좁은 반경”에서만 뽑지 말 것.
- 아래 중 1개 이상을 적용해 “넓은 영역에서 자연스럽게 시작”되게:
- A) 스폰 반경 확대: vp 기준 반경 **120~360px** 범위에서 랜덤 분포
- B) 링/도넛 분포: 반경 80~320px에서 더 자주 뽑고(중심은 덜),
중심 반경 0~60px 영역은 스폰 확률을 낮춤
- C) 화면 전체 분포 + z/스케일로 전진감 유지: (x,y)는 화면 랜덤, z로 원근만 제공
- 단, “완전 랜덤”으로 바꾸면 전진감이 약해질 수 있으니,
소실점(vp)을 기준으로 하는 투영 구조는 유지하되 **초기 분포만 넓혀**라.
### 2) 속도 감속(필수)
- 전체 속도를 1차로 20~40% 낮춘다(체감상 확실히 느려져야 함).
- 속도 티어를 아래 범위로 조정(권장):
- 느림(75~90%): 0.004 ~ 0.009
- 중간(10~25%): 0.009 ~ 0.014
- 빠름(0~3%): 0.014 ~ 0.018 (가능하면 0~2%로 제한)
- “워프처럼 길게 늘어지는” 인상을 줄이기 위해,
꼬리 길이도 속도와 함께 약간 줄이되 ‘안 보임’이 생기지 않게 하한 유지:
- 꼬리 있는 별(10~25%)의 길이: **4~10px** (하한 4px 유지)
### 3) 소실점 위치(권장)
- 소실점(vp)은 화면 정중앙보다 **살짝 위** 유지(예: -40~-90px).
- 완전 대칭을 피하기 위해 x도 소폭 오프셋(±10~25px, 고정) 가능.
### 4) 밀도/가시성 유지(필수)
- 저밀도 유지:
- 데스크탑 18~45 / 모바일 12~30
- 가시성 하한 유지:
- 일반 alpha 0.25~0.55, 강조 0.55~0.85
- radius 일반 0.7~1.2, 강조 1.2~1.8
### 5) 리사이클(필수)
- 화면 밖/z 임계치 도달 시 재스폰하되,
재스폰도 위 “넓은 스폰 분포” 규칙을 적용한다(중심에만 몰지 말 것).
### 6) 모션 접근성(필수)
- prefers-reduced-motion: reduce:
- 정지(우선) 또는 speed 10~20%로 감속
## Non-scope
- UI(A안) 변경 금지
- 타이머 로직 변경 금지
- 신규 기능 추가 금지
- 다른 페이지 변경 금지
## 적용 파일
- 필수: `@src/app/flight/page.tsx`
## 완료 조건(AC)
- [ ] 별 생성이 중심점에 과도하게 몰리지 않고 넓은 영역에서 자연스럽게 시작한다
- [ ] 속도가 확실히 느려져 “유영/조용한 전진” 느낌이 난다(워프/가속 인상 없음)
- [ ] 가시성은 유지되어 별이 충분히 보인다(너무 희미해지지 않음)
- [ ] 저밀도 범위(데스크탑 18~45 / 모바일 12~30)가 유지된다
- [ ] UI는 변경되지 않았다
- [ ] prefers-reduced-motion에서 정지 또는 크게 약화된다
## 완료 후 출력(최소)
- 수정된 파일 경로 목록
- AC 항목별 체크 결과(OK/NO)

View File

@@ -0,0 +1,64 @@
# .gemini/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Home 별 모양을 Flight 스타일로 통일 + glint 유지(십자/그라데이션/블룸, opacity only)
- TASK_SLUG: home-stars-match-flight-keep-glint
## 작업 목표
`@src/app/page.tsx`(Home)의 별(점) 모양을 flight에서 사용하는 “별 점 스타일”과 일관되게 맞춘다.
단, Home은 기존 요구대로 **glint(십자 빛 번짐)**가 반드시 존재해야 한다.
Home 별자리 3개(오리온/마차부/북두칠성) 구조는 유지한다.
## 작업 요구사항
1. 별 코어 스타일 통일(필수)
- Home 별의 core(점/원) 크기/밝기 범위를 flight의 별 느낌과 비슷하게 조정:
- 일반: radius 0.7~1.2, alpha 0.25~0.55
- 강조: radius 1.2~1.8, alpha 0.55~0.85
- 과하게 큰 “노드/버튼” 느낌이 나면 실패.
2. glint 유지(필수)
- 각 별은 십자(+) 글린트를 가진다(대각선 × 금지).
- 스파이크는 끝으로 갈수록 사라지게(그라데이션 stroke) 구현한다.
- 블룸은 1겹만(과하지 않게).
3. 애니메이션 제약(최우선)
- Home glint/twinkle 애니메이션은 **opacity만** 변화한다.
- transform(scale/translate/rotate) 전부 금지.
- 별자리/별 위치 이동 금지.
4. 타이밍 분산(필수)
- 별마다 duration(2~6s)과 delay 분산(고정 랜덤 OK).
5. 모션 접근성(필수)
- prefers-reduced-motion: reduce 에서는 애니메이션 OFF(정지 우선) 또는 매우 약화.
## Non-scope
- Home의 카드/CTA/레이아웃 변경 금지(별 스타일만).
- 다른 페이지 변경 금지.
- 신규 기능 추가 금지.
## 적용 파일
- 필수: `@src/app/page.tsx`
- 필요 시(최소): 글로벌 CSS 유틸리티
## 완료 조건(AC)
- [ ] Home 별의 core 크기/밝기가 flight의 별 느낌과 유사해졌다(노드 느낌 감소)
- [ ] Home 별에 십자(+) glint가 유지되며, 끝이 그라데이션으로 사라진다
- [ ] 애니메이션은 opacity only, transform 0, 위치 이동 0
- [ ] prefers-reduced-motion에서 정지 또는 크게 약화된다
## 완료 후 출력(최소)
- 수정된 파일 경로 목록
- AC 항목별 체크 결과(OK/NO)

View File

@@ -0,0 +1,70 @@
# .gemini/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Lobby 배경 별(코어) 더 작고 더 진하게 튜닝
- TASK_SLUG: lobby-stars-smaller-darker-cores
## 작업 목표
`@src/app/page.tsx` 로비 배경 별자리(오리온/마차부/북두칠성)의 별(코어)이
지금보다 **반지름이 더 작고**, **색/불투명도가 더 진하게** 보이도록 조정한다.
glint(십자 빛 번짐) 및 그라데이션/블룸 구조는 유지한다.
## 작업 요구사항
1. 코어(중심 점) 크기 축소(필수)
- 별 코어(circle) radius를 전체적으로 15~35% 축소한다.
- 권장 목표 범위:
- 일반 별: r 0.6 ~ 1.0
- 강조 별: r 1.0 ~ 1.4
- “노드/버튼”처럼 보이면 실패(작고 선명하게).
2. 코어 색/진하기 강화(필수)
- 코어가 더 “진하게” 보이도록 alpha/색을 조정한다.
- 권장 목표 범위(배경이 어두운 기준):
- 일반 별: opacity 0.55 ~ 0.85
- 강조 별: opacity 0.85 ~ 1.0
- 단, 과도한 눈부심/번쩍임 금지(특히 중앙에 몰려 보이면 실패).
3. glint/블룸은 유지하되 과하지 않게(필수)
- glint(십자)와 그라데이션 stroke는 유지한다.
- 코어를 진하게 만들면서 glint가 더 튀면 아래 중 하나 적용:
- glint 피크 opacity를 10~20% 낮춤
- 블룸 레이어 opacity를 소폭 낮춤(<= 0.20 권장)
- 목표: “작고 진한 별 + 은은한 글린트”
4. 애니메이션 제약 유지(필수)
- twinkle/glint 애니메이션은 opacity only 유지
- transform(scale/translate/rotate) 금지
- 별/별자리 위치 이동 금지
- prefers-reduced-motion: reduce 시 정지 또는 크게 약화
## Non-scope
- 로비 카드/CTA/레이아웃 변경 금지
- 별자리 종류/개수 변경 금지(3개 유지)
- 다른 페이지 변경 금지
- 신규 기능 추가 금지
## 적용 파일
- 필수: `@src/app/page.tsx`
- (허용) 필요 시 최소 스타일 조정 파일
## 완료 조건(AC)
- [ ] 별 코어가 확실히 더 작아졌다(전체적으로 15~35% 감소 체감)
- [ ] 별 코어가 확실히 더 진하게 보인다(대비 상승)
- [ ] glint는 유지되며 과하게 튀지 않는다(필요 시 소폭 감쇠)
- [ ] opacity-only 애니메이션, transform 0, 위치 이동 0
- [ ] prefers-reduced-motion에서 정지 또는 크게 약화
## 완료 후 출력(최소)
- 수정된 파일 경로 목록
- AC 항목별 체크 결과(OK/NO)

View File

@@ -0,0 +1,131 @@
# .gemini/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Home 별자리 별(코어) 더 작게 + Flight 스타필드 속도 추가 감속
- TASK_SLUG: home-stars-smaller-and-flight-slower
## 작업 우선순위(중요)
1. Home(`@src/app/page.tsx`) 별자리 별(코어) 크기 축소
2. Flight(`@src/app/flight/page.tsx`) 별 이동 속도 추가 감속
- 두 작업은 서로 로직을 섞지 말 것(Home SVG/glint 유지, Flight 캔버스/스타필드 유지)
---
# A. Home 작업(필수)
## 작업 목표
Home의 각 별자리(오리온/마차부/북두칠성)에서 별(코어)이 지금보다 **더 작게** 보이도록 조정한다.
glint(십자)와 그라데이션/블룸, opacity-only 애니메이션 제약은 그대로 유지한다.
## 작업 요구사항
1. 코어 크기 축소(필수)
- 별 코어(circle) radius를 전체적으로 추가로 15~30% 축소한다.
- 권장 목표 범위:
- 일반 별: r 0.5 ~ 0.9
- 강조 별: r 0.9 ~ 1.2
- “너무 희미해져 안 보임”이 생기면, 크기는 유지하고 opacity를 소폭 올려 해결한다(크기 되돌리기 금지 우선).
2. glint 유지(필수)
- 십자(+) glint 유지(대각선 × 금지)
- 그라데이션 stroke + 블룸 1겹 유지(과하지 않게)
- 코어가 작아지면서 glint가 상대적으로 커 보이면:
- glint arm 길이를 10~20% 축소 또는
- glint 피크 opacity를 10~20% 낮춘다
3. 애니메이션/모션 제약(필수)
- 애니메이션은 opacity only
- transform(scale/translate/rotate) 금지
- 별/별자리 위치 이동 금지
- prefers-reduced-motion: reduce에서 정지 또는 크게 약화
## Home Non-scope
- 로비 카드/CTA/레이아웃 변경 금지
- 별자리 3개 구성 변경 금지
- 신규 기능 추가 금지
## Home 적용 파일
- `@src/app/page.tsx`
## Home 완료 조건(AC)
- [ ] 별 코어가 눈에 띄게 더 작아졌다(추가 15~30% 감소 체감)
- [ ] glint(십자) 유지, 과하게 튀지 않음(필요 시 길이/피크 감쇠)
- [ ] opacity-only, transform 0, 위치 이동 0
- [ ] prefers-reduced-motion에서 정지 또는 크게 약화
---
# B. Flight 작업(필수)
## 현재 문제(관찰)
- Flight 스타필드가 여전히 속도가 빠르게 느껴져 유영이 아니라 가속/워프처럼 보인다.
## 작업 목표
`@src/app/flight/page.tsx` 배경 스타필드의 체감 속도를 **추가로 감속**하여
“조용한 전진/유영” 느낌을 강화한다. (가시성은 유지)
## 작업 요구사항
1. 속도 추가 감속(필수)
- 현재 속도 대비 체감상 15~30% 추가 감속한다.
- 권장 speed 티어(이 범위 안으로 재조정):
- 느림(80~92%): 0.003 ~ 0.007
- 중간(8~18%): 0.007 ~ 0.011
- 빠름(0~2%): 0.011 ~ 0.014
- “빠름 티어”는 가능하면 0~1%로 더 줄여도 됨.
2. 꼬리/가시성 유지(필수)
- 속도를 줄이면 꼬리가 더 안 보일 수 있으므로,
꼬리 있는 별(10~25%)의 길이 하한은 유지:
- 4~10px 범위 유지(하한 4px)
- 너무 안 보이면 “속도를 다시 올리지 말고”
alpha(밝기) 또는 꼬리 비율(점:꼬리)을 소폭 조정해 해결한다.
3. 중앙 보호/저밀도 유지(필수)
- 저밀도 범위 유지: 데스크탑 18~45 / 모바일 12~30
- 중앙 UI 가독성 유지(중앙 보호 규칙 유지)
4. 모션 접근성(필수)
- prefers-reduced-motion: reduce에서 정지(우선) 또는 크게 감속
## Flight Non-scope
- Flight UI(A안) 레이아웃/버튼/미션 변경 금지(배경만)
- 타이머 로직 변경 금지
- 신규 기능 추가 금지
## Flight 적용 파일
- `@src/app/flight/page.tsx`
## Flight 완료 조건(AC)
- [ ] 체감 속도가 확실히 느려져 유영/조용한 전진 느낌이 강화되었다
- [ ] 가시성이 유지된다(별이 충분히 보임)
- [ ] 저밀도 범위/중앙 가독성 유지
- [ ] prefers-reduced-motion에서 정지 또는 크게 약화
- [ ] UI 변경 없음
---
## 완료 후 출력(최소)
- 수정된 파일 경로 목록
- Home AC 체크(OK/NO)
- Flight AC 체크(OK/NO)

View File

@@ -0,0 +1,102 @@
# .gemini/current.md
## TASK_META (수정 금지)
- TASK_TITLE: FSD 1차 구조로 리팩토링(동작 동일) — lobby/flight 시각 컴포넌트 및 로직 분리
- TASK_SLUG: fsd-refactor-phase1-no-behavior-change
## 작업 목표
현재 `@src/app/page.tsx`(Home/Lobby)와 `@src/app/flight/page.tsx`에 섞여있는
UI/스타필드/글린트/타이머 관련 코드를 **FSD 구조로 분리**하여 읽기 쉽게 만든다.
- 핵심: **동작/디자인/출력 결과는 변경하지 않는다.**
- 변경은 “파일 분리 + import 정리 + 타입 분리” 중심으로 한다.
## 리팩토링 원칙(필수)
- 기능 추가 금지 / UI 변경 금지 / 애니메이션 변경 금지
- 기존 동작과 시각 결과를 유지(가능하면 스냅샷/수동 확인)
- 변수명/함수명은 의미가 더 명확해지면 최소한으로 개선 가능(대규모 리네이밍 금지)
- CSS 유틸리티/글로벌 스타일은 이동하더라도 동일하게 동작해야 함
## 목표 FSD 디렉토리(제안, Next App Router 호환)
아래 구조로 생성/이동하라:
- `src/shared/`
- `ui/` : 재사용 UI(버튼/칩/베일 등)
- `lib/` : 유틸(랜덤 시드, clamp, prefersReducedMotion, resize observer 등)
- `config/` : 상수/튜닝 값(별 개수 범위, speed 티어 등)
- `src/features/`
- `lobby-starfield/`
- `ui/` : ConstellationScene, Star, GlintCross 등(로비 배경 전용)
- `model/` : 별자리 좌표/별 티어 데이터(오리온/마차부/북두칠성)
- `flight-starfield/`
- `ui/` : FlightStarfieldCanvas (배경 캔버스 레이어)
- `model/` : Star 객체 타입, spawn/recycle, speed tiers, vp 설정 등
- `lib/` : 투영/렌더 루프 헬퍼(순수 함수)
- `src/widgets/` (페이지에 꽂는 단위)
- `lobby-background/` : 로비 배경 위젯(장면 전체)
- `flight-background/` : flight 배경 위젯
- `flight-hud/` : flight 타이머/미션/버튼 UI 위젯(있다면)
- `src/pages/` 는 App Router라 사용하지 말 것(생성 금지)
## 구체 작업 범위
### A) Home/Lobby 분리(필수)
- `src/app/page.tsx`에서 로비 배경(별자리+glint) 관련 JSX/CSS 의존을 분리:
- 새로운 위젯/피처 컴포넌트로 이동
- page.tsx는 “레이아웃 + CTA + 배경 위젯 호출”만 남긴다
- 별자리 좌표/티어/스타 크기/opacity 등의 상수는 `shared/config` 또는 `features/lobby-starfield/model`로 이동
### B) Flight 분리(필수)
- `src/app/flight/page.tsx`에서 스타필드 캔버스 로직을 분리:
- rAF 루프, star state, spawn/recycle, speed tiers 등은 `features/flight-starfield`로 이동
- page.tsx는 “HUD(UI) + 배경 위젯 호출”만 남긴다
- prefers-reduced-motion 처리도 shared/lib로 이동(중복 제거)
### C) import 정리(필수)
- 절대경로/alias 사용 규칙이 있으면 그것에 맞추고, 없으면 통일(한 방식만)
- barrel export(index.ts)는 필요 최소로만(과도한 index 남발 금지)
### D) 타입/상수 정리(권장)
- star 타입, 티어 타입, 좌표 타입 분리
- “튜닝 값”을 한 파일에 모아 관리 가능하게(예: `shared/config/starfield.ts`)
- 단, 값 자체는 바꾸지 말 것(동작 유지)
## Non-scope
- 디자인 변경, 애니메이션 변경, 별 개수/속도 변경 금지
- 라우팅 변경 금지
- 신규 기능 추가 금지
## 적용 파일(예상)
- `@src/app/page.tsx`
- `@src/app/flight/page.tsx`
- 신규 생성:
- `src/features/lobby-starfield/**`
- `src/features/flight-starfield/**`
- `src/widgets/**`
- `src/shared/lib/**`, `src/shared/config/**`
## 완료 조건(AC)
- [ ] page.tsx / flight/page.tsx의 코드가 “페이지 조합” 수준으로 짧아졌다(핵심 로직 제거)
- [ ] 로비 별자리+glint는 features/widgets로 분리되었고 동작 동일
- [ ] flight 스타필드 캔버스는 features/widgets로 분리되었고 동작 동일
- [ ] 빌드/타입 에러 없이 동작한다
- [ ] 기능/디자인 변경 없이 리팩토링만 수행했다
## 완료 후 출력(최소)
- 변경/생성된 파일 경로 목록
- AC 체크(OK/NO)

View File

@@ -0,0 +1,168 @@
# .gemini/current.md
## TASK_META (수정 금지)
- TASK_TITLE: FSD 정착 2차 — 잔재 import 제거/중복 정리 + widgets public API 고정 + 분리작업 준비
- TASK_SLUG: fsd-settle-phase2-cleanup-and-public-api
## 작업 목표
1차 FSD 이관 이후 남아있는 “잔재(import/중복/브릿지)”를 정리하고,
페이지(app/\*)가 **widgets의 public API만** import 하도록 고정해
향후 기능/스타필드/UI 분리 작업을 안전하게 진행할 수 있는 상태로 만든다.
**중요:** 동작/디자인 변경 없이 리팩토링(정리/분리)만 수행한다.
---
## 0) 작업 순서(반드시 이 순서로)
1. 잔재 import 탐지 → 2) 잔재 제거/이관 → 3) 중복 소거(단일 소스) →
2. widgets public API(index.ts) 구축 → 5) 페이지 import를 widgets로 통일 →
3. 브릿지 파일 제거 → 7) 빌드 확인
---
# 1) 잔재(import) 탐지 및 제거(필수)
## 탐지(필수)
레포에서 아래 경로를 직접 참조하는 import가 남아있는지 모두 확인하고 제거한다:
- `components/*`
- `lib/*`
- `types/*` (가능하면)
- `features/*/ui/*` 를 page가 직접 import 하는 케이스(페이지는 widgets만)
> 탐지는 CLI에서 ripgrep을 사용하거나(권장), 코드 검색으로 동일하게 수행한다.
## 정리 규칙(필수)
- `components/*`를 직접 import하는 코드가 있다면:
- 해당 컴포넌트의 “정식 위치”(shared/ui, features/**/ui, widgets/**/ui)로 import를 바꾼다.
- `lib/*` import가 남아있다면:
- `shared/lib/**` 또는 `shared/config/**` 또는 `entities/**/model`/`features/**/model`로 교체한다.
- `types/*` import가 남아있다면:
- 가능 범위에서 `shared/types/**` 또는 도메인 model로 이동/교체한다.
- 단, 타입 대수술은 금지(경로 정리 중심).
---
# 2) 중복 구현 정리(필수)
## 대상(필수)
특히 아래 성격의 파일은 중복이 생기기 쉬우므로 “단일 소스”로 통일한다:
- `LobbyBackground` 계열
- `FlightBackground` 계열
- Star/Glint/Constellation 컴포넌트
- Starfield Canvas/Loop 관련 유틸
## 규칙(필수)
- 동일 기능이 두 위치에 존재하면, 하나를 “정식 소스”로 결정하고 나머지는 제거한다.
- 정식 소스 위치 우선순위:
1. widgets/\*\* (페이지에 꽂는 단위)
2. features/\*\* (기능 단위)
3. shared/\*\* (완전 범용)
- 제거가 리스크면 임시 re-export 브릿지를 1회만 허용하되,
이번 작업에서 최종적으로는 브릿지를 제거하는 방향을 우선한다.
---
# 3) Widgets Public API 고정(핵심)
## 목표(필수)
페이지(app/\*)는 앞으로 아래만 import 한다:
- `widgets/**``index.ts` (public API)
## 수행(필수)
- 각 widget 폴더에 `index.ts`를 만들고 public export를 한 곳으로 모은다.
예:
- `src/widgets/lobby-background/index.ts`
- `src/widgets/flight-background/index.ts`
- `src/widgets/flight-hud/index.ts`
- `src/widgets/lobby-routes/index.ts`
- page에서 `widgets/**/ui/*` 또는 `features/**/ui/*` 를 직접 import하는 코드를 전부 제거하고,
`widgets/**`의 index.ts에서만 가져오게 수정한다.
## 금지(필수)
- page에서 features/entities/shared를 직접 import하지 말 것(예외: 정말 공용 상수 1~2개 정도만, 가능하면 위젯이 먹도록 감춘다).
- widget 내부에서만 features/entities/shared를 조합한다.
---
# 4) 페이지(app/\*) 정리(필수)
## 목표(필수)
- `src/app/page.tsx``src/app/flight/page.tsx`는 “조합자” 역할만 한다:
- 위젯 렌더링 + 라우팅/레이아웃
- 별자리/스타필드/글린트/타이머 코어 로직은 페이지에서 제거되어야 한다(이미 분리되어 있다면 유지).
## 수행(필수)
- `app/page.tsx``LobbyBackground`(widget)와 라우트/CTA만 남긴다.
- `app/flight/page.tsx``FlightBackground` + `FlightHUD`(widget)만 남긴다.
---
# 5) 브릿지 파일 제거(필수)
## 목표
- 1차 이관에서 남겨둔 임시 re-export/브릿지 파일이 있다면 제거한다.
- `components/` 또는 `lib/` 폴더가 남아있다면:
- 비어있게 만들고 제거하거나,
- 최소한 “더 이상 import되지 않음” 상태로 만든다(최종적으로 삭제 권장).
---
# 6) 빌드/런타임 확인(필수)
- 타입 체크/빌드가 깨지지 않아야 한다.
- Home 로비 배경(별자리+glint) 정상 표시
- Flight 배경 스타필드 정상 표시
- 주요 라우트 이동(/, /boarding, /flight, /debrief, /log, /settings) 깨짐 없음
---
## Non-scope
- 기능 추가/변경 금지
- UI/애니메이션/스타필드 튜닝 금지(이번 작업은 구조 고정만)
- 라우트/디자인 변경 금지
- 스타일 값 조정 금지
---
## 적용 범위(예상)
- `src/app/page.tsx`
- `src/app/flight/page.tsx`
- `src/widgets/**` (index.ts 생성/수정)
- `src/features/**` (중복 제거/정식 소스 확정)
- `src/shared/**` (유틸/설정 경로 정리)
- 잔재 폴더(`components`, `lib`, `types`) 정리/삭제
---
## 완료 조건(AC)
- [ ] `components/*`, `lib/*`, `types/*` 직접 import가 레포에서 0이거나(최소화), 남아있다면 정당한 사유가 주석으로 명시됨
- [ ] 페이지(app/\*)는 widgets public API만 import한다(직접 features/ui 참조 없음)
- [ ] 각 widget은 index.ts(public API)를 가진다
- [ ] 중복 구현이 제거되어 단일 소스가 확정되었다
- [ ] 빌드/타입 에러 없이 동작이 유지된다
---
## 완료 후 출력(최소)
- 변경/생성/삭제된 파일 경로 목록
- AC 체크(OK/NO)

View File

@@ -0,0 +1,88 @@
# .cli/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Lobby 목표 설정 화면을 모달로 전환 + 메모 기능 제거(동작 유지)
- TASK_SLUG: lobby-boarding-modal-remove-memo
## 작업 목표
- 우주정거장(로비)에서 항로 선택 후 **“바로 출항”**을 눌렀을 때 이동하던 목표 설정 화면을
**페이지 이동 대신 모달로 띄우는 방식**으로 변경한다.
- 목표 설정 화면의 **메모 기능은 완전히 삭제**한다.
- 모달에서 **“도킹 완료(출항)”**를 누르면 기존과 동일하게 다음 플로우(출항/flight 진입)가 진행되도록 한다.
## 사용자 플로우(필수)
1. 로비(/)에서 항로 카드의 “바로 출항” 클릭
2. (페이지 이동 없음) 목표 설정 모달 오픈
3. 사용자가 목표(미션) 입력
4. “도킹 완료(출항)” 클릭
5. 기존과 동일한 방식으로 세션 시작 및 flight 화면으로 이동(또는 기존 출항 로직 호출)
## 작업 요구사항
### A) 모달 전환(필수)
- “바로 출항” 클릭 시 `/boarding`로 라우팅하지 말고,
로비 페이지에서 목표 설정 모달을 연다.
- 모달 닫기(취소/배경 클릭/ESC 등) 시 로비로 그대로 돌아온다(세션 시작 금지).
- 모달 오픈 시 미션 입력 필드에 자동 포커스.
### B) 메모 기능 삭제(필수)
- 목표 설정 UI에서 메모 입력(컨디션 메모 등) 관련:
- UI 요소 제거
- 상태(state) 제거
- 저장/로그 스키마에 기록하던 필드가 있다면 더 이상 생성하지 않음(기존 데이터 읽기는 깨지지 않게)
- 관련 placeholder/문구도 제거.
### C) “도킹 완료(출항)” 동작 동일(필수)
- 모달에서 “도킹 완료(출항)” 클릭 시:
- 기존 boarding 페이지에서 하던 세션 생성/저장 로직을 그대로 재사용한다
- 성공하면 기존과 동일하게 flight로 이동한다
- 유효성:
- 미션 입력은 필수(빈 값이면 출항 불가 + 기존과 동일한 방식의 에러 처리)
### D) /boarding 라우트 처리(권장)
- “바로 출항” 플로우는 모달로 전환하되,
`/boarding` 페이지는 당장 제거하지 말고(링크/북마크 대비),
동작을 유지하거나 로비로 리다이렉트하는 방식 중 하나로 정리한다.
- 단, 새로운 기능 범위를 넘어 UI/흐름을 크게 바꾸지 말 것.
## 구현 가이드(권장)
- 모달 컴포넌트는 이미 있는 공용 Dialog/Modal을 사용한다(`shared/ui/dialog` 또는 동일 역할 컴포넌트).
- 모달 내부 폼은 기존 boarding 폼을 재사용 가능한 컴포넌트로 분리해도 됨(동작 동일 유지).
- 세션 시작 로직은 `features/*-session/model` 등 기존 모델을 그대로 호출(중복 구현 금지).
## Non-scope
- flight/debrief/log/settings UI 변경 금지
- 스타필드/애니메이션 튜닝 금지
- 신규 기능 추가 금지(메모 삭제 + 모달 전환만)
## 적용 파일(예상)
- `@src/app/page.tsx` (로비에서 모달 제어)
- `@src/app/boarding/page.tsx` (필요 시 리다이렉트/호환 처리)
- 관련 위젯/피처:
- `widgets/lobby-routes` 또는 로비 카드/출항 핸들러 위치
- `widgets/boarding-screen` 또는 목표 설정 UI 컴포넌트
- 공용 모달/다이얼로그 컴포넌트(이미 있으면 재사용)
## 완료 조건(AC)
- [ ] 로비에서 “바로 출항” 클릭 시 /boarding로 이동하지 않고 목표 설정 모달이 열린다
- [ ] 모달 닫으면 아무 것도 시작되지 않고 로비에 그대로 남는다
- [ ] 메모 입력/UI/상태/저장 로직이 모두 제거되었다
- [ ] 미션이 비어있으면 출항 불가(기존과 동일한 유효성 처리)
- [ ] “도킹 완료(출항)” 클릭 시 기존과 동일하게 세션이 시작되고 flight로 이동한다
- [ ] 기존 로그/세션 데이터 읽기 흐름이 깨지지 않는다(기존 데이터 호환)
## 완료 후 출력(최소)
- 수정/생성/삭제된 파일 경로 목록
- AC 체크 결과(OK/NO)

View File

@@ -0,0 +1,79 @@
# .cli/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Enter로 진행 — 탑승(미션 설정) & 회고(항해일지) 폼을 form submit로 처리
- TASK_SLUG: enter-submit-boarding-and-debrief
## 작업 목표
1. 탑승(미션 설정) 화면/모달에서 사용자가 **Enter**를 누르면 “도킹 완료(출항)”과 동일하게 다음 흐름으로 진행되게 한다.
2. 도착/회고(항해일지 작성) 화면/모달에서도 **Enter**를 누르면 “저장”과 동일하게 제출되어 다음 흐름으로 진행되게 한다.
3. 멀티라인 입력(textarea)이 있다면 Enter 제출로 인해 입력이 불편해지지 않게 예외 처리를 한다.
## 적용 범위
- 탑승(미션 설정) UI: 페이지(`/boarding`) 및/또는 로비에서 띄우는 탑승 모달(현재 구현 기준)
- 회고(항해일지) UI: 페이지(`/debrief`) 및/또는 flight 종료 시 띄우는 회고 모달(현재 구현 기준)
## 작업 요구사항
### A) 탑승(미션 설정) — Enter 제출(필수)
- 미션 입력 UI를 `<form>`으로 감싼다.
- “도킹 완료(출항)” 버튼은 `type="submit"`으로 처리한다.
- Enter 키 입력 시 `onSubmit`이 실행되어 기존 출항 로직이 동일하게 호출되도록 한다.
- 유효성(미션 필수)은 기존과 동일하게 유지한다.
- 제출 성공 시 기존과 동일한 다음 화면(보통 flight)으로 이동한다.
### B) 회고(항해일지) — Enter 제출(필수)
- 회고 입력 UI를 `<form>`으로 감싼다.
- “저장” 버튼은 `type="submit"`으로 처리한다.
- Enter 키 입력 시 `onSubmit`이 실행되어 기존 저장 로직이 동일하게 호출되도록 한다.
- 제출 성공 후 흐름은 기존과 동일하게 유지한다(로그 상세/목록/로비 등 현재 구현 따름).
### C) textarea/멀티라인 입력 예외(중요)
- 회고 질문이나 입력칸이 textarea(멀티라인)라면:
- 기본 Enter는 줄바꿈이 되어야 한다(제출로 가로채지 않음).
- 대신 **Cmd/Ctrl + Enter**로 제출되게 하거나,
- textarea는 Enter 줄바꿈 유지 + 제출은 버튼/단축키로만(둘 중 하나 선택)
- 단, 현재 입력이 전부 단일라인 input이면 Enter=submit로 단순 처리한다.
### D) 모달 환경(필수)
- 모달(탑승/회고)에서 Enter 제출이 동작하되,
포커스 트랩/닫기 정책과 충돌하지 않게 한다.
- 제출 중 중복 submit 방지(버튼 disabled 또는 guard) 적용(권장).
### E) 접근성(권장)
- form submit 시 에러가 있으면 입력 필드에 포커스 이동 또는 에러 메시지 노출(현재 방식 유지)
## Non-scope
- 문구/UX 대개편 금지
- 데이터 스키마 변경 금지
- 다른 페이지 UI 변경 금지
## 적용 파일(예상)
- 탑승 UI 위치:
- `@src/app/boarding/page.tsx` 또는 `widgets/boarding-screen/**` 또는 로비 탑승 모달 컴포넌트
- 회고 UI 위치:
- `@src/app/debrief/page.tsx` 또는 `widgets/debrief-screen/**` 또는 flight 회고 모달 컴포넌트
- flight 모달 트리거가 있는 경우:
- `@src/app/flight/page.tsx` (필요 시 submit 핸들러 연결만)
## 완료 조건(AC)
- [ ] 탑승 화면/모달에서 Enter를 누르면 “도킹 완료(출항)”과 동일하게 진행된다
- [ ] 회고 화면/모달에서 Enter를 누르면 “저장”과 동일하게 진행된다
- [ ] textarea가 있으면 Enter 줄바꿈이 보장되며(또는 Ctrl/Cmd+Enter 제출), 입력 경험이 깨지지 않는다
- [ ] 유효성/저장/이동 로직은 기존과 동일하게 동작한다(중복 구현 없음)
## 완료 후 출력(최소)
- 수정/생성/삭제된 파일 경로 목록
- AC 체크 결과(OK/NO)

View File

@@ -0,0 +1,56 @@
# .cli/current.md
## TASK_META (수정 금지)
- TASK_TITLE: 우주정거장(무제한 체류) 타이머 count-up 복구 및 HH:MM:SS 표시
- TASK_SLUG: station-timer-countup-hhmmss
## 문제(관찰)
- 오리온/쌍둥이자리처럼 카운트다운이 있는 항로는 정상 동작한다.
- 우주정거장처럼 0초부터 제한 없이 머무는 항로는 타이머가 `00:00`에 고정되어 증가하지 않는다.
## 작업 목표
- 우주정거장 항로에서 경과 시간이 `00:00`부터 정상적으로 증가하고, 1시간 이상 체류를 위해 `HH:MM:SS` 포맷으로 표시되게 한다.
## 합리적 가정
- 우주정거장 타이머는 카운트다운이 아닌 경과 시간(count-up) 기준으로 동작한다.
- 표시 포맷은 항상 `HH:MM:SS`(예: `00:00:05`, `01:02:03`)로 통일한다.
## 적용 범위
- 우주정거장 항로의 타이머 계산 로직
- 우주정거장 항로의 타이머 렌더링 포맷
## 작업 요구사항
1. 우주정거장(무제한 체류) 모드에서 타이머가 `00:00`에 멈추지 않고 1초 단위로 증가해야 한다.
2. 타이머 표시는 분까지만이 아니라 초까지 포함한 `HH:MM:SS` 형식을 사용해야 한다.
3. 1시간 미만일 때도 자리수를 유지해 `00:MM:SS` 형식으로 표시해야 한다.
4. 기존 카운트다운 항로(30분/60분)의 진행 로직은 회귀 없이 유지해야 한다.
## Non-scope
- 항로별 제한 시간 정책 변경 금지
- 타이머 외 UI/문구/레이아웃 변경 금지
- 로그 저장 스키마/세션 모델 확장 금지
## 적용 파일(예상)
- `src/app/flight/page.tsx`
- `src/features/flight-session/**` (타이머 계산 훅/모델)
- `src/shared/lib/**` (시간 포맷 유틸, 필요 시)
## 완료 조건(AC)
- [ ] 우주정거장 항로 진입 시 타이머가 `00:00:00`에서 시작해 지속 증가한다
- [ ] 우주정거장 타이머가 `HH:MM:SS` 형식으로 렌더링된다
- [ ] 1시간 이상 체류 시 `01:00:00` 이상으로 정상 표기된다
- [ ] 오리온/쌍둥이자리 카운트다운 동작은 기존과 동일하게 유지된다
## 완료 후 출력(최소)
- 수정/생성/삭제된 파일 경로 목록
- AC 체크 결과(OK/NO)

View File

@@ -0,0 +1,55 @@
# .cli/current.md
## TASK_META (수정 금지)
- TASK_TITLE: 오리온/쌍둥이자리 카운트다운 타이머를 HH:MM:SS로 표시
- TASK_SLUG: countdown-routes-timer-hhmmss-display
## 문제(관찰)
- 오리온, 쌍둥이자리 항로의 타이머 표시는 현재 시:분:초 형태가 아니다.
## 작업 목표
- 오리온/쌍둥이자리 타이머 표시를 `HH:MM:SS` 형식(예: `00:30:00`, `01:00:00`)으로 통일한다.
## 합리적 가정
- 이번 작업은 표시 포맷만 변경하며, 카운트다운 동작/제한 시간(30분, 60분)은 유지한다.
- 우주정거장(무제한 체류) 타이머 정책 변경은 이번 범위에 포함하지 않는다.
## 적용 범위
- 오리온/쌍둥이자리 항로의 타이머 렌더링 포맷 로직
- 시간 문자열 포맷 유틸(필요 시)
## 작업 요구사항
1. 오리온(30분) 타이머 초기값이 `00:30:00` 형태로 표시되어야 한다.
2. 쌍둥이자리(60분) 타이머 초기값이 `01:00:00` 형태로 표시되어야 한다.
3. 카운트다운 진행 중에도 항상 `HH:MM:SS` 자리수를 유지해야 한다.
4. 타이머의 감소 속도/종료 조건 등 기존 카운트다운 로직은 변경하지 않는다.
## Non-scope
- 우주정거장 타이머 로직/표시 변경 금지
- 항로별 제한 시간 정책 변경 금지
- 타이머 외 UI/문구/레이아웃 변경 금지
## 적용 파일(예상)
- `src/app/flight/page.tsx`
- `src/features/flight-session/**` (카운트다운 표시 계산 위치)
- `src/shared/lib/**` (시간 포맷 유틸, 필요 시)
## 완료 조건(AC)
- [ ] 오리온 타이머가 `HH:MM:SS` 형식으로 표시된다
- [ ] 쌍둥이자리 타이머가 `HH:MM:SS` 형식으로 표시된다
- [ ] 카운트다운 동작(감소/종료)은 기존과 동일하게 유지된다
- [ ] 우주정거장 동작에는 영향이 없다
## 완료 후 출력(최소)
- 수정/생성/삭제된 파일 경로 목록
- AC 체크 결과(OK/NO)

View File

@@ -0,0 +1,56 @@
# .cli/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Flight 화면 "이번 항해 목표" 뷰 UI/UX 개선
- TASK_SLUG: flight-mission-view-ux-polish
## 문제(관찰)
- Flight 화면에서 목표 문구가 단순 인용문 형태로 노출되어 정보 위계가 약하고, 가독성과 완성도가 떨어진다.
## 작업 목표
- Flight HUD 내 목표 영역을 카드형 정보 블록으로 개선해, 목표 인지성/가독성/시각적 완성도를 높인다.
## 합리적 가정
- 이번 작업은 목표 뷰의 UI/UX 개선에 한정하며, 타이머/일시정지/종료 같은 항해 동작 로직은 변경하지 않는다.
- 기존 비주얼 톤(우주 배경, 다크 기반)은 유지한다.
## 적용 범위
- 목표 표시 UI 컴포넌트: `src/widgets/flight-hud/ui/FlightHudWidget.tsx`
- 필요 시 공용 스타일 유틸/프리미티브: `src/shared/ui/**` (최소 변경)
## 작업 요구사항
1. 목표 문구 영역을 단순 인용문에서 카드형 블록으로 변경한다.
2. 카드 내부에 라벨(예: `이번 항해 목표`)과 본문 텍스트를 분리해 정보 위계를 명확히 한다.
3. 긴 목표 문구가 레이아웃을 깨지 않도록 줄바꿈/최대 높이/말줄임 처리 중 하나를 적용해 모바일/데스크톱 모두 안정적으로 보이게 한다.
4. 텍스트 대비(contrast)와 간격(spacing)을 개선해 현재보다 읽기 쉬운 상태를 만든다.
5. 기존 상단 상태 배지, 중앙 타이머, 하단 버튼 동작은 그대로 유지한다.
## Non-scope
- 타이머 계산/표시 포맷 로직 변경 금지
- 항해 제어 버튼(일시정지/종료) 기능 변경 금지
- 배경 이펙트/스타필드 로직 변경 금지
- 신규 라우트/상태 모델 추가 금지
## 적용 파일(예상)
- `src/widgets/flight-hud/ui/FlightHudWidget.tsx`
- `src/shared/ui/**` (필요한 경우에만)
## 완료 조건(AC)
- [ ] Flight 화면 목표 영역이 카드형 UI로 변경되어 라벨과 본문이 분리되어 보인다
- [ ] 긴 목표 문구 입력 시에도 레이아웃 깨짐 없이 반응형으로 표시된다
- [ ] 목표 텍스트 가독성(대비/행간/여백)이 기존 대비 개선된다
- [ ] 타이머/버튼 기능과 항해 흐름은 기존과 동일하게 동작한다
## 완료 후 출력(최소)
- 수정/생성/삭제된 파일 경로 목록
- AC 체크 결과(OK/NO)

View File

@@ -0,0 +1,61 @@
# .cli/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Flight 종료 후 회고를 모달로 전환하고 회고 폼 항목/아이콘 정리
- TASK_SLUG: flight-finish-debrief-modal-and-copy-cleanup
## 문제(관찰)
- Flight 화면에서 `항해 종료` 클릭 시 별도 `/debrief` 페이지로 이동한다.
- 회고 폼에 `다음 항해의 첫 행동 (Next)` 항목이 포함되어 있다.
- 회고 상태 옵션 라벨에 이모지(✅, 🌓, 🧭)가 사용되어 톤 일관성이 떨어진다.
## 작업 목표
- Flight 내에서 회고 작성이 모달로 완료되도록 전환하고, 회고 폼을 간결하게 정리한다.
## 합리적 가정
- 모달 회고 저장 완료 후 최종 이동 경로는 기존과 동일하게 `/log`를 유지한다.
- 기존 `/debrief` 라우트는 즉시 삭제하지 않고 호환 경로로 남긴다(리다이렉트 또는 동일 폼 재사용).
- 이모지 제거는 텍스트 라벨 정리 또는 아이콘 컴포넌트 대체 중 하나로 처리한다.
## 적용 범위
- flight 종료 액션 트리거/상태 연결: `src/features/flight-session/model/useFlightSession.ts`
- flight HUD(모달 오픈/렌더): `src/widgets/flight-hud/ui/FlightHudWidget.tsx`
- 회고 폼(UI/필드/옵션 라벨): `src/app/debrief/page.tsx` (필요 시 공용 컴포넌트로 분리)
## 작업 요구사항
1. Flight 화면에서 `항해 종료` 클릭 시 페이지 전환 대신 회고 모달이 열려야 한다.
2. 회고 모달에서 저장/취소 흐름이 정상 동작해야 하며, 저장 시 기존과 동일하게 항해일지 저장 후 `/log`로 이동해야 한다.
3. 회고 폼에서 `다음 항해의 첫 행동 (Next)` 입력 항목을 제거한다.
4. 회고 상태 옵션의 이모지(✅, 🌓, 🧭, 🚨 등)를 제거하고, 문구 중심 또는 아이콘 컴포넌트 방식으로 일관되게 정리한다.
5. 기존 데이터 구조와의 호환을 깨지 않도록 저장 로직을 유지한다(불필요 필드만 제거/미기록 처리).
## Non-scope
- Flight 타이머/배경/조작 버튼(일시정지 등) 동작 변경 금지
- 로그 목록/상세 페이지의 레이아웃 개편 금지
- 항해 시작(boarding) 플로우 변경 금지
## 적용 파일(예상)
- `src/features/flight-session/model/useFlightSession.ts`
- `src/widgets/flight-hud/ui/FlightHudWidget.tsx`
- `src/app/debrief/page.tsx`
## 완료 조건(AC)
- [ ] Flight에서 `항해 종료` 클릭 시 `/debrief` 페이지 이동 없이 회고 모달이 열린다
- [ ] 회고 저장 시 기존과 동일하게 저장되고 `/log`로 이동한다
- [ ] 회고 폼에서 `다음 항해의 첫 행동 (Next)` 필드가 제거된다
- [ ] 회고 상태 옵션에서 이모지가 제거되어 톤이 정리된다
- [ ] 기존 항해 저장/히스토리 읽기 흐름에 회귀가 없다
## 완료 후 출력(최소)
- 수정/생성/삭제된 파일 경로 목록
- AC 체크 결과(OK/NO)

View File

@@ -0,0 +1,53 @@
# .cli/current.md
## TASK_META (수정 금지)
- TASK_TITLE: Flight 종료 버튼 2초 길게 누르기(hold-to-confirm) + 원형 진행선 + 종료 모달 오픈
- TASK_SLUG: flight-finish-hold-2s-ring-and-modal
## 문제(관찰)
- 현재는 종료 버튼 클릭 직후 종료 플로우가 진행되어 오작동(실수 클릭) 여지가 있다.
## 작업 목표
- 종료 버튼을 2초간 길게 눌렀을 때만 종료 모달이 열리도록 변경하고, 누르는 동안 버튼 테두리 진행선을 시각적으로 제공한다.
## 합리적 가정
- 종료 모달이 열리기 전까지는 항해 상태 저장/라우팅 같은 실제 종료 처리는 발생하지 않는다.
- 길게 누르기 완료 후에는 기존 종료 모달/종료 확정 흐름을 그대로 재사용한다.
## 적용 범위
- flight HUD 종료 버튼 UI/interaction: `src/widgets/flight-hud/ui/FlightHudWidget.tsx`
- flight 종료 트리거 상태/핸들러: `src/features/flight-session/model/useFlightSession.ts`
- 종료 모달 렌더/연결 위치(필요 시): `src/app/flight/page.tsx`
## 작업 요구사항
1. 종료 버튼은 단순 클릭으로는 종료되지 않고, 최소 2초 이상 길게 누른 경우에만 종료 모달이 열린다.
2. 길게 누르는 동안 버튼 테두리를 따라 0%→100%로 진행되는 원형(또는 버튼 외곽형) 진행선이 표시된다.
3. 2초 이전에 누름을 해제하면 진행선은 즉시 취소되고 초기 상태로 복귀한다.
4. 진행선이 100%에 도달하는 시점(2초 경과)에 종료 모달이 자동 오픈된다.
5. 모달 오픈 후 실제 종료 확정/취소 동작은 기존 정책과 동일하게 유지된다.
## Non-scope
- 타이머 계산/표시 로직 변경 금지
- 일시정지 버튼 동작 변경 금지
- 배경/스타필드/다른 페이지 UI 개편 금지
- 신규 라우트 추가 금지
## 완료 조건(AC)
- [ ] 종료 버튼 단순 클릭(짧은 터치/클릭)으로는 종료 모달이 열리지 않는다
- [ ] 2초 길게 누르면 종료 모달이 열린다
- [ ] 누르는 동안 버튼 테두리 진행선이 시간 경과에 맞춰 표시된다
- [ ] 2초 이전 해제 시 진행선이 초기화되고 아무 종료 동작도 발생하지 않는다
- [ ] 모달 이후 확정/취소 흐름은 기존과 동일하게 동작한다
## 완료 후 출력(최소)
- 수정/생성/삭제된 파일 경로 목록
- AC 체크 결과(OK/NO)

View File

@@ -0,0 +1,56 @@
# .cli/current.md
## TASK_META (수정 금지)
- TASK_TITLE: 종료 버튼 길게 누르기 진행선 비주얼 어색함 개선(원형 대안 적용)
- TASK_SLUG: finish-hold-visual-alt-for-non-circle-button
## 문제(관찰)
- 종료 버튼은 원형이 아닌데, 원형 진행선(또는 원형에 가까운 테두리 애니메이션) 표현이 버튼 형태와 맞지 않아 어색하다.
## 작업 목표
- 버튼 형태와 일치하는 진행 피드백으로 교체해, 길게 누르기(2초) 인터랙션을 자연스럽고 명확하게 보이게 한다.
## 합리적 가정
- 이번 작업은 시각 피드백 방식 개선이 핵심이며, `2초 길게 누르면 종료 모달 오픈` 동작 규칙은 유지한다.
- 원형 진행선 대신 버튼 내부 수평 진행(fill) 또는 버튼 외곽 라운드 사각 진행선 중 하나를 채택한다.
## 적용 범위
- 종료 버튼 UI/hold 시각 피드백: `src/widgets/flight-hud/ui/FlightHudWidget.tsx`
- hold 진행 상태 계산/노출(필요 시): `src/features/flight-session/model/useFlightSession.ts`
## 작업 요구사항
1. 원형 진행선 기반 표현을 제거하고, 버튼 형태와 일치하는 진행 피드백으로 교체한다.
2. 진행 피드백은 0%~100%가 2초 동안 선형으로 증가하며, 사용자가 즉시 인지 가능해야 한다.
3. 눌렀다 떼면 진행률은 즉시 0으로 리셋되어야 한다.
4. 100% 도달 시 기존과 동일하게 종료 모달이 열린다.
5. 모바일/데스크톱 모두에서 텍스트 가독성과 버튼 상태(기본/누름/완료 직전)가 명확해야 한다.
## Non-scope
- 종료 모달 내부 UI/카피 개편 금지
- 타이머/일시정지/배경 이펙트 변경 금지
- 항해 저장/라우팅 정책 변경 금지
## 적용 파일(예상)
- `src/widgets/flight-hud/ui/FlightHudWidget.tsx`
- `src/features/flight-session/model/useFlightSession.ts` (필요 시)
## 완료 조건(AC)
- [ ] 종료 버튼에서 원형 진행선이 제거되고 버튼 형태와 일치한 진행 피드백이 적용된다
- [ ] 2초 길게 누르는 동안 진행 상태가 자연스럽게 증가해 보인다
- [ ] 2초 이전 해제 시 진행 상태가 즉시 초기화된다
- [ ] 2초 완료 시 종료 모달이 기존 규칙대로 열린다
- [ ] 기존 종료 플로우(모달 이후 동작)에 회귀가 없다
## 완료 후 출력(최소)
- 수정/생성/삭제된 파일 경로 목록
- AC 체크 결과(OK/NO)

4
.gitignore vendored
View File

@@ -40,3 +40,7 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
.idea
.cli/tasks
.cli/_task_context.md
.cli/changelog.md
.cli/planner/input.md

29
AGENTS.md Normal file
View File

@@ -0,0 +1,29 @@
# AGENTS.md (개발자/Builder 기본)
## 역할
당신은 개발자(Builder)입니다. 작업 결과는 **레포지토리에 직접 적용**해야 합니다.
## 단일 진실(필수)
- 항상 `@.cli/runbook.md`를 따른다.
- 현재 작업 지시는 `@.cli/current.md`가 단일 진실이다.
- 구조/규칙 문서: `@.cli/docs/architecture.md`, `@.cli/docs/rules.md`
## 범위 규칙
- `@.cli/current.md`에 명시된 범위/파일만 수정한다.
- 불필요한 리팩토링/포맷 변경/스타일 변경 금지.
- `.cli/**`는 housekeeping에서 요구될 때만 수정한다.
## 빌드/테스트
- `npm run build` 등 명령 실행은 `@.cli/ops/run_housekeeping.md`가 지시할 때만 수행한다.
## 출력(최소)
- 출력은 최소로 한다:
1. 수정/생성/삭제된 파일 경로 목록
2. AC 체크리스트(OK/NO)
3. housekeeping 결과 1줄
- 코드 전체 덤프/긴 로그 출력 금지.

23
components.json Normal file
View File

@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

23
docker/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

View File

@@ -0,0 +1,16 @@
services:
hushroom-web:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: hushroom-web
restart: unless-stopped
environment:
- NODE_ENV=production
networks:
- npm_net
networks:
npm_net:
external: true
name: proxy

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "standalone",
};
export default nextConfig;

1827
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,14 @@
"lint": "eslint"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -21,6 +26,7 @@
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

52
src/app/boarding/page.tsx Normal file
View File

@@ -0,0 +1,52 @@
'use client';
import { Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ROUTES } from '@/shared/config/routes';
import { BoardingMissionForm, startVoyage } from '@/features/boarding';
import { useI18n } from '@/features/i18n/model/useI18n';
function BoardingContent() {
const { t } = useI18n();
const router = useRouter();
const searchParams = useSearchParams();
const routeId = searchParams.get('routeId');
const route = ROUTES.find(r => r.id === routeId) || ROUTES[0];
const routeName = t(route.nameKey, undefined, route.id);
const handleDocking = (mission: string) => {
const started = startVoyage({ route, mission, routeName });
if (!started) return;
router.push('/flight');
};
return (
<div className="flex flex-col flex-1 p-6 animate-in slide-in-from-bottom-4 duration-500">
<div className="mb-8">
<h2 className="text-sm text-indigo-400 font-semibold mb-1 uppercase tracking-widest">
{t('boarding.check')}
</h2>
<h1 className="text-3xl font-bold text-white">
{t('boarding.routeBoarding', { routeName })}
</h1>
</div>
<div className="space-y-8 flex-1">
<BoardingMissionForm
onDock={handleDocking}
autoFocus={true}
/>
</div>
</div>
);
}
export default function BoardingPage() {
const { t } = useI18n();
return (
<Suspense fallback={<div className="p-6">{t('common.loading')}</div>}>
<BoardingContent />
</Suspense>
);
}

115
src/app/debrief/page.tsx Normal file
View File

@@ -0,0 +1,115 @@
'use client';
import { FormEvent, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useI18n } from '@/features/i18n/model/useI18n';
import { DEBRIEF_STATUS_OPTIONS } from '@/shared/config/i18n';
import { getCurrentVoyage, saveToHistory, saveCurrentVoyage } from '@/shared/lib/store';
import { Voyage, VoyageStatus } from '@/shared/types';
export default function DebriefPage() {
const { t } = useI18n();
const router = useRouter();
const [voyage, setVoyage] = useState<Voyage | null>(null);
const [status, setStatus] = useState<VoyageStatus | null>(null);
const [progress, setProgress] = useState('');
useEffect(() => {
const current = getCurrentVoyage();
if (!current) {
router.replace('/');
return;
}
setVoyage(current);
}, [router]);
const handleSave = () => {
if (!voyage || !status) return;
const finalVoyage: Voyage = {
...voyage,
status: status,
debriefProgress: progress,
endedAt: voyage.endedAt || Date.now(), // Fallback if missed in flight
};
saveToHistory(finalVoyage);
saveCurrentVoyage(null);
router.push('/log');
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
handleSave();
};
if (!voyage) return null;
const statusOptions = DEBRIEF_STATUS_OPTIONS.map((option) => ({
value: option.value as VoyageStatus,
label: t(option.labelKey),
desc: t(option.descKey),
}));
return (
<div className="flex flex-col flex-1 p-6 max-w-2xl mx-auto w-full animate-in zoom-in-95 duration-500">
<header className="mb-8 text-center">
<h1 className="text-2xl font-bold text-white mb-2">{t('debrief.page.title')}</h1>
<p className="text-slate-400">{t('debrief.page.description')}</p>
</header>
<form onSubmit={handleSubmit} className="space-y-8 flex-1">
{/* Question 1: Status */}
<section>
<label className="block text-sm font-medium text-slate-300 mb-3">
{t('debrief.status.label')}
</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{statusOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setStatus(opt.value)}
className={`flex min-h-[116px] flex-col justify-between rounded-xl border px-4 py-3.5 text-left transition-all ${
status === opt.value
? 'bg-indigo-900/40 border-indigo-500 ring-1 ring-indigo-500'
: 'bg-slate-900/50 border-slate-800 hover:bg-slate-800'
}`}
>
<div className="text-sm leading-snug font-bold text-slate-200 break-keep">
{opt.label}
</div>
<div className="mt-2 text-[11px] leading-relaxed text-slate-500">
{opt.desc}
</div>
</button>
))}
</div>
</section>
{/* Question 2: Reflection */}
<section>
<label className="block text-sm font-medium text-slate-300 mb-2">
{t('debrief.reflection.label')}
</label>
<input
type="text"
value={progress}
onChange={(e) => setProgress(e.target.value)}
placeholder={t('debrief.reflection.placeholder')}
className="w-full bg-slate-900/30 border border-slate-800 rounded-lg px-4 py-3 text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-all"
/>
</section>
<button
type="submit"
disabled={!status}
className="w-full mt-10 py-4 bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-900/20"
>
{t('debrief.save')}
</button>
</form>
</div>
);
}

16
src/app/flight/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
'use client';
import { useFlightSession } from '@/features/flight-session/model/useFlightSession';
import { FlightBackgroundWidget } from '@/widgets/flight-background';
import { FlightHudWidget } from '@/widgets/flight-hud';
export default function FlightPage() {
const session = useFlightSession();
return (
<div className="relative flex min-h-[calc(100vh-64px)] flex-1 flex-col items-center justify-center overflow-hidden p-6 text-white">
<FlightBackgroundWidget isPaused={session.isPaused} />
<FlightHudWidget {...session} />
</div>
);
}

View File

@@ -1,26 +1,244 @@
@import "tailwindcss";
@import "tw-animate-css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(15.152% 0.01301 277.362);
--primary: oklch(0.574 0.202 262);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.62 0.202 262);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
@layer utilities {
@keyframes twinkle {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
@keyframes star-core-pulse {
0%,
38%,
100% {
opacity: var(--core-low, 0.25);
}
52% {
opacity: var(--core-high, 0.55);
}
}
@keyframes star-glint-pulse {
0%,
35%,
100% {
opacity: var(--glint-base, 0.02);
}
50% {
opacity: var(--glint-peak, 0.82);
}
60% {
opacity: calc(var(--glint-peak, 0.82) * 0.12);
}
}
@keyframes star-bloom-pulse {
0%,
35%,
100% {
opacity: var(--bloom-base, 0.01);
}
50% {
opacity: var(--bloom-peak, 0.18);
}
60% {
opacity: calc(var(--bloom-peak, 0.18) * 0.2);
}
}
.animate-twinkle {
animation: twinkle 4s ease-in-out infinite;
}
.star-core {
animation-name: star-core-pulse;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
opacity: var(--core-low, 0.25);
fill: currentColor;
}
.star-core-bloom {
animation-name: star-bloom-pulse;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
opacity: var(--bloom-base, 0.01);
fill: currentColor;
filter: blur(0.7px);
}
.star-glint {
animation-name: star-glint-pulse;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
opacity: var(--glint-base, 0.02);
stroke-width: 0.62;
stroke-linecap: round;
}
.star-glint-bloom {
animation-name: star-bloom-pulse;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
opacity: var(--bloom-base, 0.01);
stroke-width: 0.9;
stroke-linecap: round;
filter: blur(0.65px);
}
@media (prefers-reduced-motion: reduce) {
.animate-twinkle {
animation: none;
opacity: 0.8;
}
.star-core {
animation: none;
opacity: var(--core-reduced, 0.34);
}
.star-core-bloom {
animation: none;
opacity: 0.08;
}
.star-glint {
animation: none;
opacity: 0.08;
}
.star-glint-bloom {
animation: none;
opacity: 0.04;
}
}
}

View File

@@ -1,20 +1,10 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
import { I18nLayoutShell } from "@/features/i18n/ui/I18nLayoutShell";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Focustella",
description: "Space-themed focus timer",
};
export default function RootLayout({
@@ -23,11 +13,9 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<html lang="en" className="dark">
<body className="bg-slate-950 text-slate-100 min-h-screen font-sans selection:bg-indigo-500/30 overflow-x-hidden">
<I18nLayoutShell>{children}</I18nLayoutShell>
</body>
</html>
);

79
src/app/log/[id]/page.tsx Normal file
View File

@@ -0,0 +1,79 @@
'use client';
import { useEffect, useState, use } from 'react';
import Link from 'next/link';
import { useI18n } from '@/features/i18n/model/useI18n';
import { VOYAGE_STATUS_LABEL_KEYS } from '@/shared/config/i18n';
import { findRouteById } from '@/shared/config/routes';
import { getHistory } from '@/shared/lib/store';
import { Voyage } from '@/shared/types';
export default function LogDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { t, locale } = useI18n();
// Next.js 15: params is a Promise
const resolvedParams = use(params);
const [log, setLog] = useState<Voyage | null>(null);
useEffect(() => {
const history = getHistory();
const found = history.find(item => item.id === resolvedParams.id);
if (found) setLog(found);
}, [resolvedParams.id]);
if (!log) {
return <div className="p-6 text-slate-500">{t('log.detail.loadingOrNotFound')}</div>;
}
const route = findRouteById(log.routeId);
const routeName = route ? t(route.nameKey, undefined, log.routeName) : log.routeName;
const statusLabel = t(VOYAGE_STATUS_LABEL_KEYS[log.status], undefined, t('status.in_progress'));
const missionLabel = (() => {
const trimmed = log.missionText.trim();
if (!trimmed || trimmed === '미입력') {
return t('log.mission.empty');
}
return log.missionText;
})();
return (
<div className="flex flex-col flex-1 p-6 space-y-8 animate-in fade-in duration-300">
<header className="border-b border-slate-800 pb-4">
<div className="mb-4 flex items-center gap-4 text-sm">
<Link href="/log" className="text-indigo-400 transition-colors hover:text-indigo-300">
&larr; {t('log.detail.back')}
</Link>
<Link href="/" className="text-indigo-400 transition-colors hover:text-indigo-300">
&larr; Lobby
</Link>
</div>
<h1 className="text-2xl font-bold text-white">{missionLabel}</h1>
<div className="flex gap-3 mt-2 text-sm text-slate-500">
<span>{new Date(log.startedAt).toLocaleString(locale)}</span>
<span></span>
<span>{routeName} ({log.durationMinutes}{t('common.minuteShort')})</span>
</div>
</header>
<div className="space-y-6">
<div className="bg-slate-900/30 p-4 rounded-lg border border-slate-800/50">
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t('log.detail.statusTitle')}</h3>
<p className="text-lg text-indigo-100">{statusLabel}</p>
</div>
<div>
<div>
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t('log.detail.progressTitle')}</h3>
<p className="text-slate-300 leading-relaxed bg-slate-900/20 p-3 rounded">{log.debriefProgress || '-'}</p>
</div>
</div>
{log.notes && (
<div>
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t('log.detail.initialNoteTitle')}</h3>
<p className="text-slate-400 text-sm italic">"{log.notes}"</p>
</div>
)}
</div>
</div>
);
}

86
src/app/log/page.tsx Normal file
View File

@@ -0,0 +1,86 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useI18n } from '@/features/i18n/model/useI18n';
import { VOYAGE_STATUS_LABEL_KEYS } from '@/shared/config/i18n';
import { findRouteById } from '@/shared/config/routes';
import { getHistory } from '@/shared/lib/store';
import { Voyage, VoyageStatus } from '@/shared/types';
export default function LogListPage() {
const { t, locale } = useI18n();
const [logs, setLogs] = useState<Voyage[]>([]);
useEffect(() => {
setLogs(getHistory());
}, []);
const getStatusLabel = (s: VoyageStatus) => {
return t(VOYAGE_STATUS_LABEL_KEYS[s], undefined, t('status.in_progress'));
};
const getMissionLabel = (missionText: string) => {
const trimmed = missionText.trim();
if (!trimmed || trimmed === '미입력') {
return t('log.mission.empty');
}
return missionText;
};
return (
<div className="flex flex-col flex-1 p-6">
<div className="mb-6 flex items-center justify-between gap-3">
<h1 className="text-xl font-bold text-slate-100">{t('log.title')}</h1>
<Link
href="/"
className="text-sm text-indigo-400 transition-colors hover:text-indigo-300"
>
&larr; Lobby
</Link>
</div>
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center flex-1 py-20 text-slate-500 border border-dashed border-slate-800 rounded-xl">
<p className="mb-4">{t('log.empty')}</p>
<Link href="/" className="text-indigo-400 hover:text-indigo-300 underline">
{t('log.firstVoyage')}
</Link>
</div>
) : (
<div className="grid gap-3">
{logs.map((log) => {
const route = findRouteById(log.routeId);
const routeName = route
? t(route.nameKey, undefined, log.routeName)
: log.routeName;
return (
<Link
key={log.id}
href={`/log/${log.id}`}
className="block bg-slate-900/50 border border-slate-800 hover:border-slate-600 rounded-lg p-4 transition-colors"
>
<div className="flex justify-between items-start mb-1">
<span className="text-xs text-slate-500">
{new Date(log.startedAt).toLocaleDateString(locale)}
</span>
<span className="text-xs font-medium text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded">
{getStatusLabel(log.status)}
</span>
</div>
<h3 className="font-semibold text-slate-200 truncate mb-1">
{getMissionLabel(log.missionText)}
</h3>
<p className="text-xs text-slate-500">
{routeName} · {log.durationMinutes}
{t('common.minuteShort')}
</p>
</Link>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,86 +1,13 @@
// app/page.tsx
"use client";
'use client';
import { useRouter } from "next/navigation";
import { useCallback } from "react";
type Mode = "freeflow" | "sprint" | "deepwork";
export default function HomePage() {
const router = useRouter();
const go = useCallback(
(mode: Mode) => router.push(`/session?mode=${mode}`),
[router],
);
import { LobbyBackgroundWidget } from '@/widgets/lobby-background';
import { LobbyRoutesPanel } from '@/widgets/lobby-routes';
export default function Home() {
return (
<main className="min-h-screen w-full bg-[#E9EEF6]">
<header className="px-5 pt-6">
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
hushroom
<div className="relative left-1/2 right-1/2 -mx-[50vw] flex w-screen flex-1 flex-col animate-in fade-in duration-500">
<LobbyBackgroundWidget />
<LobbyRoutesPanel />
</div>
</header>
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col justify-center px-5 pb-10">
<div className="mb-4">
<div className="text-sm font-semibold text-slate-600"> </div>
<div className="mt-1 text-base leading-relaxed text-slate-700">
,
</div>
</div>
<button
type="button"
onClick={() => go("freeflow")}
className="w-full rounded-3xl bg-[#2F6FED] px-8 py-6 text-left text-white shadow-sm transition active:scale-[0.99] hover:bg-[#295FD1]"
aria-label="프리플로우"
>
<div className="text-2xl font-semibold"></div>
<div className="mt-2 text-lg opacity-90"></div>
</button>
<div className="my-8 flex items-center gap-3">
<div className="h-px flex-1 bg-[#D7E0EE]" />
<div className="text-sm font-semibold text-slate-600">
</div>
<div className="h-px flex-1 bg-[#D7E0EE]" />
</div>
<div className="-mt-2 mb-5 text-base leading-relaxed text-slate-700">
</div>
<div className="grid grid-cols-2 gap-4">
<ModeTile title="스프린트" meta="25분" onClick={() => go("sprint")} />
<ModeTile title="딥워크" meta="90분" onClick={() => go("deepwork")} />
</div>
</section>
</main>
);
}
function ModeTile({
title,
meta,
onClick,
}: {
title: string;
meta: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="rounded-3xl border border-[#C9D7F5] bg-white px-7 py-5 text-left shadow-sm transition active:scale-[0.99] hover:bg-[#F1F5FF]"
aria-label={`${title} ${meta}`}
>
<div className="flex items-baseline justify-between">
<div className="text-xl font-semibold text-slate-900">{title}</div>
<div className="text-lg font-semibold text-blue-700">{meta}</div>
</div>
</button>
);
}

View File

@@ -1,93 +0,0 @@
// app/session/end/page.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useMemo } from "react";
type Mode = "freeflow" | "sprint" | "deepwork";
const BG = "#E9EEF6";
const BORDER = "#C9D7F5";
const PRIMARY = "#2F6FED";
const PRIMARY_HOVER = "#295FD1";
function clampMode(v: string | null): Mode {
if (v === "sprint" || v === "deepwork" || v === "freeflow") return v;
return "freeflow";
}
function modeLabel(mode: Mode) {
if (mode === "sprint") return "스프린트";
if (mode === "deepwork") return "딥워크";
return "프리플로우";
}
function hhmmss(total: number) {
const s = Math.max(0, Math.floor(total));
const hh = String(Math.floor(s / 3600)).padStart(2, "0");
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
const ss = String(s % 60).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
export default function SessionEndPage() {
const router = useRouter();
const params = useSearchParams();
const mode = useMemo(() => clampMode(params.get("mode")), [params]);
const elapsed = useMemo(() => {
const v = Number(params.get("elapsed") ?? "0");
return Number.isFinite(v) ? v : 0;
}, [params]);
return (
<main className="min-h-screen w-full" style={{ backgroundColor: BG }}>
<header className="px-5 pt-6">
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
hushroom
</div>
</header>
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col justify-center px-5 pb-10 pt-6">
<div
className="rounded-3xl border bg-white px-6 py-6 shadow-sm"
style={{ borderColor: BORDER }}
>
<div className="text-sm font-semibold text-slate-600">
{modeLabel(mode)}
</div>
<div className="mt-3 text-[44px] font-semibold leading-none text-slate-900 tabular-nums">
{hhmmss(elapsed)}
</div>
<div className="mt-3 text-base text-slate-700"> </div>
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => router.push("/")}
className="rounded-3xl border bg-white px-5 py-4 text-base font-semibold text-slate-800 shadow-sm transition active:scale-[0.99]"
style={{ borderColor: BORDER }}
>
</button>
<button
type="button"
onClick={() => router.push(`/session?mode=${mode}`)}
className="rounded-3xl px-5 py-4 text-base font-semibold text-white shadow-sm transition active:scale-[0.99]"
style={{ backgroundColor: PRIMARY }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = PRIMARY_HOVER)
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = PRIMARY)
}
>
</button>
</div>
</section>
</main>
);
}

View File

@@ -1,465 +0,0 @@
// app/session/page.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
type Mode = "freeflow" | "sprint" | "deepwork";
type PresenceStatus = "focus" | "away";
type Participant = {
id: string;
status: PresenceStatus;
lastSeen: number; // ms
isSelf: boolean;
};
const BG = "#E9EEF6";
const BORDER = "#C9D7F5";
const PRIMARY = "#2F6FED";
const PRIMARY_HOVER = "#295FD1";
const HOVER = "#F1F5FF";
function clampMode(v: string | null): Mode {
if (v === "sprint" || v === "deepwork" || v === "freeflow") return v;
return "freeflow";
}
function modeLabel(mode: Mode) {
if (mode === "sprint") return "스프린트";
if (mode === "deepwork") return "딥워크";
return "프리플로우";
}
function modeDurationSeconds(mode: Mode) {
if (mode === "sprint") return 25 * 60;
if (mode === "deepwork") return 90 * 60;
return null; // freeflow
}
function formatHHMMSS(totalSeconds: number) {
const s = Math.max(0, Math.floor(totalSeconds));
const hh = String(Math.floor(s / 3600)).padStart(2, "0");
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
const ss = String(s % 60).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
/**
* 로컬 Presence (Supabase 교체 전용)
* - 같은 브라우저에서 여러 탭/창을 열면 "인원 점"이 늘어남
* - BroadcastChannel 미지원 환경은 localStorage 이벤트로 fallback
*
* 교체 포인트:
* const { participants } = useLocalPresence(roomKey, status)
* 를
* const { participants } = useSupabasePresence(roomKey, status)
* 같은 형태로 바꾸면 UI는 그대로 유지 가능
*/
function useLocalPresence(roomKey: string, status: PresenceStatus) {
const selfId = useMemo(() => {
// 탭 단위 고유 ID
const key = `hushroom:selfId:${roomKey}`;
const existing = sessionStorage.getItem(key);
if (existing) return existing;
// 짧고 충돌 확률 낮은 ID
const id = (crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`)
.replace(/[^a-zA-Z0-9]/g, "")
.slice(0, 16);
sessionStorage.setItem(key, id);
return id;
}, [roomKey]);
const [participants, setParticipants] = useState<Participant[]>(() => [
{ id: selfId, status, lastSeen: Date.now(), isSelf: true },
]);
const participantsRef = useRef<Map<string, Omit<Participant, "isSelf">>>(
new Map([[selfId, { id: selfId, status, lastSeen: Date.now() }]]),
);
const channelRef = useRef<BroadcastChannel | null>(null);
const heartbeatRef = useRef<number | null>(null);
const cleanupRef = useRef<number | null>(null);
const publish = (payload: any) => {
// BroadcastChannel 우선
if (channelRef.current) {
channelRef.current.postMessage(payload);
return;
}
// fallback: localStorage event
try {
localStorage.setItem(
`hushroom:presence:${roomKey}`,
JSON.stringify({ ...payload, _nonce: Math.random() }),
);
} catch {}
};
const syncStateToReact = () => {
const now = Date.now();
const arr: Participant[] = [];
participantsRef.current.forEach((p, id) => {
arr.push({
id,
status: p.status,
lastSeen: p.lastSeen,
isSelf: id === selfId,
});
});
// 안정적으로 정렬(자기 자신 먼저, 그 다음 최근 순)
arr.sort((a, b) => {
if (a.isSelf && !b.isSelf) return -1;
if (!a.isSelf && b.isSelf) return 1;
return b.lastSeen - a.lastSeen;
});
// self 상태 최신화(React state 변경 시 반영)
const self = participantsRef.current.get(selfId);
if (self) self.lastSeen = now;
setParticipants(arr);
};
useEffect(() => {
// 채널 세팅
if ("BroadcastChannel" in window) {
const bc = new BroadcastChannel(`hushroom-presence:${roomKey}`);
channelRef.current = bc;
bc.onmessage = (ev) => {
const msg = ev.data;
if (!msg || msg.roomKey !== roomKey) return;
if (!msg.from) return;
if (msg.type === "ping") {
participantsRef.current.set(msg.from, {
id: msg.from,
status: msg.status as PresenceStatus,
lastSeen: msg.ts ?? Date.now(),
});
syncStateToReact();
}
if (msg.type === "leave") {
participantsRef.current.delete(msg.from);
syncStateToReact();
}
};
} else {
// fallback: storage event
const onStorage = (e: StorageEvent) => {
if (e.key !== `hushroom:presence:${roomKey}` || !e.newValue) return;
try {
const msg = JSON.parse(e.newValue);
if (!msg || msg.roomKey !== roomKey) return;
if (msg.type === "ping") {
participantsRef.current.set(msg.from, {
id: msg.from,
status: msg.status as PresenceStatus,
lastSeen: msg.ts ?? Date.now(),
});
syncStateToReact();
}
if (msg.type === "leave") {
participantsRef.current.delete(msg.from);
syncStateToReact();
}
} catch {}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}
return () => {
channelRef.current?.close();
channelRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomKey]);
useEffect(() => {
// 내 상태 업데이트 + ping
const now = Date.now();
participantsRef.current.set(selfId, { id: selfId, status, lastSeen: now });
publish({ type: "ping", roomKey, from: selfId, status, ts: now });
syncStateToReact();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, roomKey, selfId]);
useEffect(() => {
// heartbeat: 2초마다 ping
heartbeatRef.current = window.setInterval(() => {
const now = Date.now();
participantsRef.current.set(selfId, {
id: selfId,
status,
lastSeen: now,
});
publish({ type: "ping", roomKey, from: selfId, status, ts: now });
syncStateToReact();
}, 2000);
// cleanup: 4초마다 오래된 참가자 제거(탭 닫힘 대비)
cleanupRef.current = window.setInterval(() => {
const now = Date.now();
const STALE_MS = 9000; // 9초 이상 ping 없으면 제거
let changed = false;
participantsRef.current.forEach((p, id) => {
if (id === selfId) return;
if (now - p.lastSeen > STALE_MS) {
participantsRef.current.delete(id);
changed = true;
}
});
if (changed) syncStateToReact();
}, 4000);
const onBeforeUnload = () => {
publish({ type: "leave", roomKey, from: selfId, ts: Date.now() });
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => {
if (heartbeatRef.current) window.clearInterval(heartbeatRef.current);
if (cleanupRef.current) window.clearInterval(cleanupRef.current);
window.removeEventListener("beforeunload", onBeforeUnload);
publish({ type: "leave", roomKey, from: selfId, ts: Date.now() });
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomKey, selfId, status]);
return { participants, selfId };
}
export default function SessionPage() {
const router = useRouter();
const params = useSearchParams();
const mode = useMemo(() => clampMode(params.get("mode")), [params]);
const duration = useMemo(() => modeDurationSeconds(mode), [mode]);
const [goal, setGoal] = useState("");
const [isAway, setIsAway] = useState(false);
// presence (로컬)
const presenceStatus: PresenceStatus = isAway ? "away" : "focus";
const roomKey = "lounge"; // 나중에 방 분리하면 여기만 바꾸면 됨
const { participants } = useLocalPresence(roomKey, presenceStatus);
// time
const [elapsed, setElapsed] = useState(0);
const [remaining, setRemaining] = useState(duration ?? 0);
// toast
const [toast, setToast] = useState<string | null>(null);
const toastTimerRef = useRef<number | null>(null);
// freeflow checkpoint every 60 minutes
const lastCheckpointRef = useRef<number>(0);
const showToast = (msg: string) => {
setToast(msg);
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
toastTimerRef.current = window.setTimeout(() => setToast(null), 8000);
};
useEffect(() => {
setElapsed(0);
if (duration) setRemaining(duration);
lastCheckpointRef.current = 0;
setIsAway(false);
setToast(null);
if (toastTimerRef.current) window.clearTimeout(toastTimerRef.current);
}, [duration]);
useEffect(() => {
const id = window.setInterval(() => {
setElapsed((prev) => prev + 1);
if (duration) {
setRemaining((prev) => {
const next = Math.max(0, prev - 1);
if (next === 0 && prev !== 0) {
router.push(`/session/end?mode=${mode}&elapsed=${duration}`);
}
return next;
});
}
}, 1000);
return () => window.clearInterval(id);
}, [duration, mode, router]);
useEffect(() => {
if (mode !== "freeflow") return;
const mins = Math.floor(elapsed / 60);
if (mins > 0 && mins % 60 === 0 && mins !== lastCheckpointRef.current) {
lastCheckpointRef.current = mins;
showToast(`${mins}분 지났어요`);
}
}, [elapsed, mode]);
const timeMain = useMemo(() => {
if (mode === "freeflow") return formatHHMMSS(elapsed);
return formatHHMMSS(remaining);
}, [elapsed, remaining, mode]);
const onCheckIn = () => showToast("체크인 기록됨");
const onEnd = () =>
router.push(`/session/end?mode=${mode}&elapsed=${elapsed}`);
return (
<main className="min-h-screen w-full" style={{ backgroundColor: BG }}>
<header className="px-5 pt-6">
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
hushroom
</div>
</header>
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col px-5 pb-10 pt-6">
{toast && (
<div className="mb-4 rounded-2xl border border-[#D7E0EE] bg-white px-4 py-3 text-sm text-slate-700 shadow-sm">
{toast}
</div>
)}
<div className="mb-4">
<div className="text-sm font-semibold text-slate-600">
{modeLabel(mode)}
</div>
<div className="mt-1 text-base leading-relaxed text-slate-700">
{mode === "freeflow"
? "원할 때 종료"
: "한 번 실행되고 끝나면 요약으로 이동"}
</div>
</div>
{/* Timer */}
<div
className="rounded-3xl border bg-white px-6 py-6 shadow-sm"
style={{ borderColor: BORDER }}
>
<div className="text-[44px] font-semibold leading-none text-slate-900 tabular-nums">
{timeMain}
</div>
<div className="mt-6">
<label className="text-sm font-semibold text-slate-600">
()
</label>
<input
value={goal}
onChange={(e) => setGoal(e.target.value)}
placeholder="이번 세션 목표(선택)"
className="mt-2 w-full rounded-2xl border px-4 py-3 text-base text-slate-900 outline-none"
style={{ borderColor: BORDER }}
/>
</div>
</div>
{/* Presence */}
<div
className="mt-4 rounded-3xl border bg-white px-6 py-5 shadow-sm"
style={{ borderColor: BORDER }}
>
<div className="flex items-center justify-between">
<div className="text-sm font-semibold text-slate-700"></div>
<div className="text-sm text-slate-600">{roomKey}</div>
</div>
<div className="mt-4 flex items-center justify-between">
<div className="text-sm font-semibold text-slate-700"></div>
<div className="text-sm text-slate-600">{participants.length}</div>
</div>
<div className="mt-4">
<PresenceDots participants={participants} />
</div>
</div>
{/* Actions */}
<div className="mt-5 grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => setIsAway((v) => !v)}
className="rounded-3xl border bg-white px-4 py-4 text-base font-semibold text-slate-800 shadow-sm transition active:scale-[0.99]"
style={{ borderColor: BORDER }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = HOVER)
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "#FFFFFF")
}
>
{isAway ? "복귀" : "자리비움"}
</button>
<button
type="button"
onClick={onCheckIn}
className="rounded-3xl border bg-white px-4 py-4 text-base font-semibold text-slate-800 shadow-sm transition active:scale-[0.99]"
style={{ borderColor: BORDER }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = HOVER)
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "#FFFFFF")
}
>
</button>
<button
type="button"
onClick={onEnd}
className="rounded-3xl px-4 py-4 text-base font-semibold text-white shadow-sm transition active:scale-[0.99]"
style={{ backgroundColor: PRIMARY }}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = PRIMARY_HOVER)
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = PRIMARY)
}
>
</button>
</div>
</section>
</main>
);
}
function PresenceDots({ participants }: { participants: Participant[] }) {
const MAX = 12;
const visible = participants.slice(0, MAX);
const extra = Math.max(0, participants.length - visible.length);
return (
<div className="flex items-center gap-2">
{visible.map((p) => (
<Dot key={p.id} status={p.status} isSelf={p.isSelf} />
))}
{extra > 0 && (
<div className="text-sm font-semibold text-slate-600">+{extra}</div>
)}
</div>
);
}
function Dot({ status, isSelf }: { status: PresenceStatus; isSelf: boolean }) {
const base = status === "away" ? "bg-slate-900/20" : "bg-slate-900/60";
const ring = isSelf ? "ring-2 ring-[#2F6FED]" : "ring-0";
return (
<div
className={`h-3 w-3 rounded-full ${base} ${ring}`}
aria-label={isSelf ? "나" : status === "away" ? "자리비움" : "집중"}
title={isSelf ? "나" : status === "away" ? "자리비움" : "집중"}
/>
);
}

42
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,42 @@
'use client';
import { useEffect, useState } from 'react';
import { useI18n } from '@/features/i18n/model/useI18n';
import { getPreferences, savePreferences } from '@/shared/lib/store';
export default function SettingsPage() {
const { t } = useI18n();
const [hideSeconds, setHideSeconds] = useState(false);
useEffect(() => {
const prefs = getPreferences();
setHideSeconds(prefs.hideSeconds);
}, []);
const handleToggle = () => {
const newValue = !hideSeconds;
setHideSeconds(newValue);
savePreferences({ hideSeconds: newValue });
};
return (
<div className="p-6">
<h1 className="text-xl font-bold text-slate-100 mb-8">{t('settings.title')}</h1>
<div className="space-y-6">
<div className="flex items-center justify-between bg-slate-900/40 p-4 rounded-xl border border-slate-800">
<div>
<h3 className="font-medium text-slate-200">{t('settings.hideSeconds.title')}</h3>
<p className="text-xs text-slate-500">{t('settings.hideSeconds.description')}</p>
</div>
<button
onClick={handleToggle}
className={`w-12 h-6 rounded-full transition-colors relative ${hideSeconds ? 'bg-indigo-600' : 'bg-slate-700'}`}
>
<div className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${hideSeconds ? 'left-7' : 'left-1'}`} />
</button>
</div>
</div>
</div>
);
}

18
src/components/Modal.tsx Normal file
View File

@@ -0,0 +1,18 @@
// import { ReactNode } from "react";
// interface ModalProps {
// open: boolean;
// onClose: () => void;
// children: ReactNode;
// }
// export default function Modal({ open, onClose, children }: ModalProps) {
// return (
// <div
// onClick={onClose}
// className={`flex fixed inset-0 justify-center items-center transition-colors ${open ? "visible bg-black/20" : "invisible"}`}
// >
// {children}
// </div>
// );
// }

View File

@@ -0,0 +1,64 @@
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/shared/lib/cn"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,158 @@
"use client";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import * as React from "react";
import { Button } from "./button";
import { cn } from "@/shared/lib/cn";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/shared/lib/cn"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,2 @@
export { BoardingMissionForm } from './ui/BoardingMissionForm';
export { startVoyage } from './model/startVoyage';

View File

@@ -0,0 +1,33 @@
import { Route, Voyage } from '@/shared/types';
import { saveCurrentVoyage } from '@/shared/lib/store';
const createVoyageId = () =>
(crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`)
.replace(/[^a-zA-Z0-9]/g, '')
.slice(0, 16);
export const startVoyage = ({
route,
mission,
routeName,
}: {
route: Route;
mission: string;
routeName: string;
}) => {
const missionText = mission.trim();
const normalizedMissionText = missionText || '미입력';
const newVoyage: Voyage = {
id: createVoyageId(),
routeId: route.id,
routeName,
durationMinutes: route.durationMinutes,
startedAt: Date.now(),
status: 'in_progress',
missionText: normalizedMissionText,
};
saveCurrentVoyage(newVoyage);
return true;
};

View File

@@ -0,0 +1,86 @@
'use client';
import { FormEvent, useState } from 'react';
import { useI18n } from '@/features/i18n/model/useI18n';
const OPTIONAL_PREFIX_PATTERN = /^(?:선택|optional|任意|facultatif)\s*[:]\s*/i;
const EXAMPLE_PREFIX_PATTERN =
/^(?:예|例|e\.?\s?g\.?|eg\.?|example|exemple|beispiel|p\.?\s?ex\.?|z\.?\s?b\.?)\s*[:)\.]\s*/i;
const buildMissionPlaceholder = (raw: string, optionalLabel: string) => {
const sanitized = raw
.trim()
.replace(OPTIONAL_PREFIX_PATTERN, '')
.replace(EXAMPLE_PREFIX_PATTERN, '')
.trim();
if (!sanitized) return '';
const normalizedOptionalLabel = optionalLabel.trim();
if (!normalizedOptionalLabel) return sanitized;
return `${sanitized} (${normalizedOptionalLabel})`;
};
export function BoardingMissionForm({
onDock,
onCancel,
autoFocus = false,
compact = false,
}: {
onDock: (mission: string) => void;
onCancel?: () => void;
autoFocus?: boolean;
compact?: boolean;
}) {
const { t } = useI18n();
const [mission, setMission] = useState('');
const trimmedMission = mission.trim();
const missionPlaceholder = buildMissionPlaceholder(
t('boarding.missionPlaceholder'),
t('boarding.optionalLabel'),
);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
onDock(trimmedMission);
};
return (
<form
onSubmit={handleSubmit}
className={`flex flex-col ${compact ? 'gap-6' : 'space-y-8 flex-1'}`}
>
<div className="space-y-3">
<label className="block text-sm font-medium text-slate-300">
{t('boarding.missionLabel')}
</label>
<input
type="text"
value={mission}
onChange={(event) => setMission(event.target.value)}
placeholder={missionPlaceholder}
className="w-full border-b-2 border-slate-700 bg-slate-900/50 px-0 py-3 text-lg outline-none transition-colors placeholder:text-slate-600 focus:border-indigo-500"
autoFocus={autoFocus}
/>
</div>
<div className={`flex ${compact ? 'justify-end gap-3' : 'mt-8 flex-col gap-3'} w-full`}>
{onCancel && (
<button
type="button"
onClick={onCancel}
className="rounded-xl border border-slate-700 bg-transparent px-4 py-2 font-semibold text-slate-300 transition-colors hover:bg-slate-900/60 hover:text-white"
>
{t('boarding.cancel')}
</button>
)}
<button
type="submit"
className={`rounded-xl bg-indigo-600 font-bold text-white transition-all shadow-lg shadow-indigo-900/30 hover:bg-indigo-500 ${compact ? 'px-6 py-2' : 'w-full py-4 text-lg'}`}
>
{t('boarding.submit')}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,141 @@
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { getCurrentVoyage, saveCurrentVoyage } from '@/shared/lib/store';
import { Voyage } from '@/shared/types';
const getVoyageFromStore = () => {
const current = getCurrentVoyage();
if (!current || current.status !== 'in_progress') {
return null;
}
return current;
};
const getEndTime = (voyage: Voyage | null) => {
if (!voyage) return 0;
return voyage.startedAt + voyage.durationMinutes * 60 * 1000;
};
const getInitialTimerSeconds = (voyage: Voyage | null) => {
if (!voyage) return 0;
if (voyage.durationMinutes === 0) {
return Math.max(0, Math.floor((Date.now() - voyage.startedAt) / 1000));
}
const endTime = getEndTime(voyage);
if (!endTime) return 0;
return Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
};
const formatHHMMSS = (totalSeconds: number) => {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes
.toString()
.padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
export function useFlightSession() {
const router = useRouter();
const [voyage, setVoyage] = useState<Voyage | null>(null);
const [timeLeft, setTimeLeft] = useState<number>(0);
const [isPaused, setIsPaused] = useState(false);
const [isSessionReady, setIsSessionReady] = useState(false);
const endTimeRef = useRef<number>(0);
const pausedElapsedMsRef = useRef<number>(0);
const pausedAtMsRef = useRef<number | null>(null);
useEffect(() => {
const storedVoyage = getVoyageFromStore();
if (storedVoyage) {
setVoyage(storedVoyage);
setTimeLeft(getInitialTimerSeconds(storedVoyage));
endTimeRef.current = getEndTime(storedVoyage);
}
setIsSessionReady(true);
}, []);
useEffect(() => {
if (!isSessionReady) return;
if (voyage) return;
router.replace('/');
}, [isSessionReady, voyage, router]);
useEffect(() => {
if (!isSessionReady || !voyage || isPaused) return;
const interval = setInterval(() => {
if (voyage.durationMinutes === 0) {
const elapsedMs = Date.now() - voyage.startedAt - pausedElapsedMsRef.current;
setTimeLeft(Math.max(0, Math.floor(elapsedMs / 1000)));
return;
}
const diff = endTimeRef.current - Date.now();
if (diff <= 0) {
setTimeLeft(0);
clearInterval(interval);
return;
}
setTimeLeft(Math.ceil(diff / 1000));
}, 1000);
return () => clearInterval(interval);
}, [isSessionReady, voyage, isPaused]);
const handlePauseToggle = () => {
if (voyage?.durationMinutes === 0) {
if (isPaused) {
if (pausedAtMsRef.current !== null) {
pausedElapsedMsRef.current += Date.now() - pausedAtMsRef.current;
}
pausedAtMsRef.current = null;
setIsPaused(false);
return;
}
pausedAtMsRef.current = Date.now();
setIsPaused(true);
return;
}
if (isPaused) {
endTimeRef.current = Date.now() + timeLeft * 1000;
setIsPaused(false);
return;
}
setIsPaused(true);
};
const handleFinish = () => {
if (!voyage) return null;
const endedVoyage: Voyage = {
...voyage,
endedAt: voyage.endedAt || Date.now(),
};
saveCurrentVoyage(endedVoyage);
return endedVoyage;
};
const formatTime = (seconds: number) => formatHHMMSS(seconds);
return {
voyage,
timeLeft,
isCountdownCompleted: Boolean(voyage && voyage.durationMinutes > 0 && timeLeft === 0),
isPaused,
formattedTime: formatTime(timeLeft),
handlePauseToggle,
handleFinish,
};
}

View File

@@ -0,0 +1 @@
export { FlightStarfieldCanvas } from './ui/FlightStarfieldCanvas';

View File

@@ -0,0 +1,21 @@
import { FlightStar, VanishingPoint } from '@/features/flight-starfield/model/types';
export const createVanishingPoint = ({
width,
height,
xJitter,
yOffset,
}: {
width: number;
height: number;
xJitter: number;
yOffset: number;
}): VanishingPoint => ({
x: width / 2 + xJitter,
y: height / 2 + yOffset,
});
export const projectFlightStar = (star: FlightStar, vp: VanishingPoint, zValue: number) => ({
x: vp.x + star.wx / zValue,
y: vp.y + star.wy / zValue,
});

View File

@@ -0,0 +1,180 @@
import { FLIGHT_STARFIELD_TUNING } from "@/shared/config/starfield";
import { clamp, randomInRange } from "@/shared/lib/math/number";
import { FlightStar } from "@/features/flight-starfield/model/types";
export const getFlightStarCount = (width: number, height: number) => {
const isMobile = width < FLIGHT_STARFIELD_TUNING.mobileBreakpoint;
const min = isMobile
? FLIGHT_STARFIELD_TUNING.starCount.mobile.min
: FLIGHT_STARFIELD_TUNING.starCount.desktop.min;
const max = isMobile
? FLIGHT_STARFIELD_TUNING.maxStars.mobile
: FLIGHT_STARFIELD_TUNING.maxStars.desktop;
const byArea = Math.round(
((width * height) / FLIGHT_STARFIELD_TUNING.densityDivisor) *
FLIGHT_STARFIELD_TUNING.densityMultiplier,
);
return clamp(byArea, min, max);
};
export const createFlightVanishXJitter = () => {
const sign = Math.random() < 0.5 ? -1 : 1;
return (
sign *
randomInRange(
FLIGHT_STARFIELD_TUNING.vanishXJitter.min,
FLIGHT_STARFIELD_TUNING.vanishXJitter.max,
)
);
};
const createFlightSpeed = () => {
const tier = Math.random();
if (tier < FLIGHT_STARFIELD_TUNING.speedTiers.slow.chance) {
return randomInRange(
FLIGHT_STARFIELD_TUNING.speedTiers.slow.min,
FLIGHT_STARFIELD_TUNING.speedTiers.slow.max,
);
}
if (tier < FLIGHT_STARFIELD_TUNING.speedTiers.medium.chance) {
return randomInRange(
FLIGHT_STARFIELD_TUNING.speedTiers.medium.min,
FLIGHT_STARFIELD_TUNING.speedTiers.medium.max,
);
}
return randomInRange(
FLIGHT_STARFIELD_TUNING.speedTiers.fast.min,
FLIGHT_STARFIELD_TUNING.speedTiers.fast.max,
);
};
const createFlightVisualTier = () => {
const highlight =
Math.random() < FLIGHT_STARFIELD_TUNING.radius.highlightChance;
const tailRoll = Math.random();
const tailLength =
tailRoll < FLIGHT_STARFIELD_TUNING.tail.pointChance
? randomInRange(
FLIGHT_STARFIELD_TUNING.tail.pointRange.min,
FLIGHT_STARFIELD_TUNING.tail.pointRange.max,
)
: tailRoll < FLIGHT_STARFIELD_TUNING.tail.shortChance
? randomInRange(
FLIGHT_STARFIELD_TUNING.tail.shortRange.min,
FLIGHT_STARFIELD_TUNING.tail.shortRange.max,
)
: randomInRange(
FLIGHT_STARFIELD_TUNING.tail.longRange.min,
FLIGHT_STARFIELD_TUNING.tail.longRange.max,
);
return {
radius: highlight
? randomInRange(
FLIGHT_STARFIELD_TUNING.radius.highlight.min,
FLIGHT_STARFIELD_TUNING.radius.highlight.max,
)
: randomInRange(
FLIGHT_STARFIELD_TUNING.radius.normal.min,
FLIGHT_STARFIELD_TUNING.radius.normal.max,
),
alpha: highlight
? randomInRange(
FLIGHT_STARFIELD_TUNING.alpha.highlight.min,
FLIGHT_STARFIELD_TUNING.alpha.highlight.max,
)
: randomInRange(
FLIGHT_STARFIELD_TUNING.alpha.normal.min,
FLIGHT_STARFIELD_TUNING.alpha.normal.max,
),
tailLength,
};
};
const createFlightSpawnRadius = (width: number, height: number) => {
const roll = Math.random();
const maxWideRadius = Math.min(
Math.max(width, height) *
FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.maxScaleOfViewport,
FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.maxAbsolute,
);
const ringOuter = Math.min(
FLIGHT_STARFIELD_TUNING.spawnRadius.ringRange.max,
maxWideRadius,
);
if (roll < FLIGHT_STARFIELD_TUNING.spawnRadius.centerChance) {
return randomInRange(
FLIGHT_STARFIELD_TUNING.spawnRadius.centerRange.min,
FLIGHT_STARFIELD_TUNING.spawnRadius.centerRange.max,
);
}
if (roll < FLIGHT_STARFIELD_TUNING.spawnRadius.ringChance) {
return randomInRange(
FLIGHT_STARFIELD_TUNING.spawnRadius.ringRange.min,
Math.max(FLIGHT_STARFIELD_TUNING.spawnRadius.ringRange.min, ringOuter),
);
}
return randomInRange(
FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.min,
Math.max(FLIGHT_STARFIELD_TUNING.spawnRadius.wideRange.min, maxWideRadius),
);
};
export const createFlightStar = ({
width,
height,
isRespawn,
}: {
width: number;
height: number;
isRespawn: boolean;
}): FlightStar => {
const radius = createFlightSpawnRadius(width, height);
const angle = randomInRange(0, Math.PI * 2);
const visuals = createFlightVisualTier();
return {
wx: Math.cos(angle) * radius,
wy: Math.sin(angle) * radius,
z: isRespawn
? randomInRange(
FLIGHT_STARFIELD_TUNING.zRange.respawn.min,
FLIGHT_STARFIELD_TUNING.zRange.respawn.max,
)
: randomInRange(
FLIGHT_STARFIELD_TUNING.zRange.initial.min,
FLIGHT_STARFIELD_TUNING.zRange.initial.max,
),
speed: createFlightSpeed(),
radius: visuals.radius,
alpha: visuals.alpha,
tailLength: visuals.tailLength,
};
};
export const shouldRecycleFlightStar = ({
x,
y,
z,
width,
height,
}: {
x: number;
y: number;
z: number;
width: number;
height: number;
}) =>
z <= FLIGHT_STARFIELD_TUNING.zRange.recycleThreshold ||
x < -50 ||
x > width + 50 ||
y < -50 ||
y > height + 50;

View File

@@ -0,0 +1,14 @@
export type FlightStar = {
wx: number;
wy: number;
z: number;
speed: number;
radius: number;
alpha: number;
tailLength: number;
};
export type VanishingPoint = {
x: number;
y: number;
};

View File

@@ -0,0 +1,325 @@
"use client";
import { useEffect, useRef } from "react";
import {
createVanishingPoint,
projectFlightStar,
} from "@/features/flight-starfield/lib/projection";
import {
createFlightStar,
createFlightVanishXJitter,
getFlightStarCount,
shouldRecycleFlightStar,
} from "@/features/flight-starfield/model/starfieldModel";
import { FlightStar } from "@/features/flight-starfield/model/types";
import { FLIGHT_STARFIELD_TUNING } from "@/shared/config/starfield";
import { clamp } from "@/shared/lib/math/number";
import { getPrefersReducedMotionMediaQuery } from "@/shared/lib/motion/prefersReducedMotion";
export function FlightStarfieldCanvas({
vanishYOffset = -68,
centerProtectRadius = 200,
isPaused = false,
}: {
vanishYOffset?: number;
centerProtectRadius?: number;
isPaused?: boolean;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const starsRef = useRef<FlightStar[]>([]);
const vanishXJitterRef = useRef<number | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
let width = window.innerWidth;
let height = window.innerHeight;
let animationFrameId = 0;
let dpr = 1;
const motionQuery = getPrefersReducedMotionMediaQuery();
let prefersReducedMotion = motionQuery.matches;
if (vanishXJitterRef.current === null) {
vanishXJitterRef.current = createFlightVanishXJitter();
}
const vanishXJitter = vanishXJitterRef.current ?? 0;
const setCanvasSize = () => {
const viewportWidth = window.visualViewport?.width ?? window.innerWidth;
const viewportHeight =
window.visualViewport?.height ?? window.innerHeight;
width = Math.max(1, Math.floor(viewportWidth));
height = Math.max(1, Math.floor(viewportHeight));
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(width * dpr));
canvas.height = Math.max(1, Math.floor(height * dpr));
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.imageSmoothingEnabled = false;
};
const snapToDevicePixel = (value: number) => Math.round(value * dpr) / dpr;
const getVanishingPoint = () =>
createVanishingPoint({
width,
height,
xJitter: vanishXJitter,
yOffset: vanishYOffset,
});
const createStars = () =>
Array.from({ length: getFlightStarCount(width, height) }, () =>
createFlightStar({ width, height, isRespawn: false }),
);
setCanvasSize();
if (starsRef.current.length === 0) {
starsRef.current = createStars();
}
let stars = starsRef.current;
const applyCenterProtection = (x: number, y: number, alpha: number) => {
const centerX = width / 2;
const centerY = height / 2;
const distance = Math.hypot(x - centerX, y - centerY);
if (distance >= centerProtectRadius) return alpha;
const ratio = clamp(distance / centerProtectRadius, 0, 1);
const attenuation = 0.35 + ratio * 0.65;
return alpha * attenuation;
};
const drawStar = ({
star,
fromX,
fromY,
toX,
toY,
}: {
star: FlightStar;
fromX: number;
fromY: number;
toX: number;
toY: number;
}) => {
const visibleAlpha = applyCenterProtection(toX, toY, star.alpha);
const deltaX = toX - fromX;
const deltaY = toY - fromY;
const movementLength = Math.hypot(deltaX, deltaY);
const tailLineWidth = clamp(star.radius * 0.9, 0.78, 1.6);
const canDrawTail =
star.tailLength >=
FLIGHT_STARFIELD_TUNING.tail.cleanup.minTailLengthToDraw &&
movementLength >=
FLIGHT_STARFIELD_TUNING.tail.cleanup.minMovementToDraw &&
visibleAlpha >= FLIGHT_STARFIELD_TUNING.tail.cleanup.minAlphaToDraw &&
tailLineWidth >=
FLIGHT_STARFIELD_TUNING.tail.cleanup.minLineWidthToDraw;
if (!canDrawTail) {
const snappedToX = snapToDevicePixel(toX);
const snappedToY = snapToDevicePixel(toY);
context.globalAlpha = visibleAlpha;
context.fillStyle = "#f8fbff";
context.beginPath();
context.arc(snappedToX, snappedToY, star.radius, 0, Math.PI * 2);
context.fill();
context.globalAlpha = 1;
return;
}
const directionX = deltaX / movementLength;
const directionY = deltaY / movementLength;
const tailX = snapToDevicePixel(toX - directionX * star.tailLength);
const tailY = snapToDevicePixel(toY - directionY * star.tailLength);
const snappedToX = snapToDevicePixel(toX);
const snappedToY = snapToDevicePixel(toY);
const gradient = context.createLinearGradient(
tailX,
tailY,
snappedToX,
snappedToY,
);
gradient.addColorStop(0, "rgba(248, 251, 255, 0)");
gradient.addColorStop(1, `rgba(248, 251, 255, ${visibleAlpha})`);
context.strokeStyle = gradient;
context.lineWidth = tailLineWidth;
context.beginPath();
context.moveTo(tailX, tailY);
context.lineTo(snappedToX, snappedToY);
context.stroke();
context.globalAlpha = Math.min(1, visibleAlpha + 0.08);
context.fillStyle = "#f8fbff";
context.beginPath();
context.arc(
snappedToX,
snappedToY,
clamp(star.radius * 0.72, 0.6, 1.45),
0,
Math.PI * 2,
);
context.fill();
context.globalAlpha = 1;
};
const drawCenterVeil = () => {
const vp = getVanishingPoint();
const veil = context.createRadialGradient(
vp.x,
vp.y,
centerProtectRadius * 0.12,
vp.x,
vp.y,
centerProtectRadius * 1.35,
);
veil.addColorStop(0, "rgba(176, 201, 242, 0.11)");
veil.addColorStop(0.22, "rgba(138, 164, 216, 0.06)");
veil.addColorStop(0.55, "rgba(90, 114, 170, 0.025)");
veil.addColorStop(1, "rgba(0, 0, 0, 0)");
context.fillStyle = veil;
context.fillRect(0, 0, width, height);
};
const drawVignette = () => {
const vignette = context.createRadialGradient(
width / 2,
height / 2,
Math.min(width, height) * 0.25,
width / 2,
height / 2,
Math.max(width, height) * 0.95,
);
vignette.addColorStop(0, "rgba(0, 0, 0, 0)");
vignette.addColorStop(1, "rgba(0, 0, 0, 0.82)");
context.fillStyle = vignette;
context.fillRect(0, 0, width, height);
};
const drawFrame = (moveStars: boolean) => {
context.setTransform(1, 0, 0, 1, 0, 0);
context.globalAlpha = 1;
context.globalCompositeOperation = "source-over";
context.clearRect(0, 0, canvas.width, canvas.height);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.fillStyle = "rgb(2, 5, 10)";
context.fillRect(0, 0, width, height);
drawCenterVeil();
stars.forEach((star, index) => {
const vp = getVanishingPoint();
const from = projectFlightStar(star, vp, star.z);
if (moveStars) {
star.z -= star.speed * FLIGHT_STARFIELD_TUNING.speedScale;
}
const to = projectFlightStar(star, vp, star.z);
if (
shouldRecycleFlightStar({
x: to.x,
y: to.y,
z: star.z,
width,
height,
})
) {
stars[index] = createFlightStar({ width, height, isRespawn: true });
starsRef.current = stars;
return;
}
drawStar({
star,
fromX: from.x,
fromY: from.y,
toX: to.x,
toY: to.y,
});
});
drawVignette();
};
const stopAnimation = () => {
if (!animationFrameId) return;
cancelAnimationFrame(animationFrameId);
animationFrameId = 0;
};
const render = () => {
if (prefersReducedMotion || isPaused) {
animationFrameId = 0;
return;
}
drawFrame(true);
animationFrameId = requestAnimationFrame(render);
};
const renderStatic = () => {
drawFrame(false);
};
const handleResize = () => {
setCanvasSize();
stars = createStars();
starsRef.current = stars;
if (prefersReducedMotion || isPaused) {
renderStatic();
}
};
const handleMotionChange = (event: MediaQueryListEvent) => {
prefersReducedMotion = event.matches;
if (prefersReducedMotion) {
stopAnimation();
renderStatic();
return;
}
if (!isPaused && !animationFrameId) {
render();
}
};
window.addEventListener("resize", handleResize);
window.visualViewport?.addEventListener("resize", handleResize);
motionQuery.addEventListener("change", handleMotionChange);
if (prefersReducedMotion || isPaused) {
renderStatic();
} else {
render();
}
return () => {
window.removeEventListener("resize", handleResize);
window.visualViewport?.removeEventListener("resize", handleResize);
motionQuery.removeEventListener("change", handleMotionChange);
stopAnimation();
};
}, [vanishYOffset, centerProtectRadius, isPaused]);
return (
<canvas
ref={canvasRef}
className="fixed inset-0 z-0 h-screen w-screen bg-black pointer-events-none"
/>
);
}

View File

@@ -0,0 +1,42 @@
import {
DEFAULT_LOCALE,
Locale,
MANUAL_LOCALE_STORAGE_KEY,
SUPPORTED_LOCALES,
} from "@/shared/config/i18n";
const normalizeLocale = (raw: string | null | undefined): Locale | null => {
if (!raw) return null;
const base = raw.trim().toLowerCase().split("-")[0];
return SUPPORTED_LOCALES.includes(base as Locale) ? (base as Locale) : null;
};
export const getManualLocale = (): Locale | null => {
if (typeof window === "undefined") return null;
return normalizeLocale(localStorage.getItem(MANUAL_LOCALE_STORAGE_KEY));
};
const getBrowserLocale = (): Locale | null => {
if (typeof navigator === "undefined") return null;
const candidates = [...(navigator.languages ?? []), navigator.language];
for (const candidate of candidates) {
const locale = normalizeLocale(candidate);
if (locale) return locale;
}
return null;
};
export const resolveInitialLocale = (): Locale => {
const manualLocale = getManualLocale();
if (manualLocale) return manualLocale;
return getBrowserLocale() ?? DEFAULT_LOCALE;
};
export const saveManualLocale = (locale: Locale) => {
if (typeof window === "undefined") return;
localStorage.setItem(MANUAL_LOCALE_STORAGE_KEY, locale);
};

View File

@@ -0,0 +1,45 @@
"use client";
import { ReactNode, createContext, useContext } from "react";
import {
DEFAULT_LOCALE,
I18nKey,
Locale,
TranslationParams,
translateText,
} from "@/shared/config/i18n";
type I18nContextValue = {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: I18nKey | string, params?: TranslationParams, fallback?: string) => string;
};
const I18nContext = createContext<I18nContextValue>({
locale: DEFAULT_LOCALE,
setLocale: () => {},
t: (key, params, fallback) =>
translateText(DEFAULT_LOCALE, key, params, fallback),
});
export function I18nProvider({
children,
locale,
setLocale,
}: {
children: ReactNode;
locale: Locale;
setLocale: (locale: Locale) => void;
}) {
const t = (key: I18nKey | string, params?: TranslationParams, fallback?: string) =>
translateText(locale, key, params, fallback);
return (
<I18nContext.Provider value={{ locale, setLocale, t }}>
{children}
</I18nContext.Provider>
);
}
export const useI18n = () => useContext(I18nContext);

View File

@@ -0,0 +1,168 @@
"use client";
import { Menu as MenuIcon, X as CloseIcon } from "lucide-react";
import Link from "next/link";
import { ChangeEvent, useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import {
DEFAULT_LOCALE,
I18nKey,
LOCALE_LABELS,
Locale,
SUPPORTED_LOCALES,
translateText,
} from "@/shared/config/i18n";
import {
resolveInitialLocale,
saveManualLocale,
} from "@/features/i18n/model/resolveInitialLocale";
import { I18nProvider } from "@/features/i18n/model/useI18n";
export function I18nLayoutShell({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const isLobby = pathname === "/";
const [locale, setLocale] = useState<Locale>(DEFAULT_LOCALE);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const menuButtonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const initialLocale = resolveInitialLocale();
setLocale(initialLocale);
document.documentElement.lang = initialLocale;
}, []);
const handleSetLocale = (nextLocale: Locale) => {
if (!SUPPORTED_LOCALES.includes(nextLocale)) return;
setLocale(nextLocale);
saveManualLocale(nextLocale);
document.documentElement.lang = nextLocale;
};
const handleLocaleChange = (event: ChangeEvent<HTMLSelectElement>) => {
const nextLocale = event.target.value as Locale;
handleSetLocale(nextLocale);
};
const t = (key: I18nKey) => translateText(locale, key);
useEffect(() => {
setIsMenuOpen(false);
}, [pathname]);
useEffect(() => {
if (!isMenuOpen) return;
const handlePointerDown = (event: PointerEvent) => {
if (!menuRef.current) return;
const target = event.target as Node | null;
if (target && !menuRef.current.contains(target)) {
setIsMenuOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setIsMenuOpen(false);
menuButtonRef.current?.focus();
};
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleEscape);
};
}, [isMenuOpen]);
return (
<I18nProvider locale={locale} setLocale={handleSetLocale}>
<div
className={`relative z-10 mx-auto flex min-h-screen flex-col ${
isLobby ? "w-full max-w-none" : "max-w-6xl"
}`}
>
<header
className={`flex items-center justify-between px-6 py-4 ${
isLobby ? "lg:hidden" : ""
}`}
>
<Link
href="/"
className="z-50 text-lg font-bold tracking-wider text-indigo-400 transition-colors hover:text-indigo-300"
>
FOCUSTELLA
</Link>
<div
ref={menuRef}
className="relative z-50"
onBlur={(event) => {
if (!isMenuOpen) return;
const nextFocused = event.relatedTarget as Node | null;
if (nextFocused && event.currentTarget.contains(nextFocused)) {
return;
}
setIsMenuOpen(false);
}}
>
<button
ref={menuButtonRef}
type="button"
onClick={() => setIsMenuOpen((prev) => !prev)}
aria-expanded={isMenuOpen}
aria-label={isMenuOpen ? "Close menu" : "Open menu"}
className="rounded-lg border border-slate-700 bg-slate-900/70 p-2 text-slate-200 transition-colors hover:border-slate-500 hover:bg-slate-800/80"
>
{isMenuOpen ? (
<CloseIcon className="h-5 w-5" aria-hidden />
) : (
<MenuIcon className="h-5 w-5" aria-hidden />
)}
</button>
{isMenuOpen && (
<div className="absolute right-0 mt-2 w-64 rounded-xl border border-slate-700 bg-slate-900/95 p-3 shadow-2xl backdrop-blur">
<nav className="flex flex-col gap-2 text-sm font-medium text-slate-300">
<Link
href="/log"
className="rounded-md px-3 py-2 transition-colors hover:bg-slate-800 hover:text-slate-100"
>
{t("layout.nav.log")}
</Link>
<Link
href="/settings"
className="rounded-md px-3 py-2 transition-colors hover:bg-slate-800 hover:text-slate-100"
>
{t("layout.nav.settings")}
</Link>
<div className="rounded-md px-3 py-2">
<label className="flex items-center justify-between gap-3 text-xs text-slate-300">
<span>{t("layout.nav.language")}</span>
<select
value={locale}
onChange={handleLocaleChange}
className="rounded border border-slate-700 bg-slate-900/70 px-2 py-1 text-xs text-slate-200 outline-none transition-colors focus:border-indigo-400"
>
{SUPPORTED_LOCALES.map((item) => (
<option key={item} value={item}>
{LOCALE_LABELS[item]}
</option>
))}
</select>
</label>
</div>
</nav>
</div>
)}
</div>
</header>
<main className="relative flex w-full flex-1 flex-col">{children}</main>
</div>
</I18nProvider>
);
}

View File

@@ -0,0 +1,15 @@
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { getCurrentVoyage } from '@/shared/lib/store';
export function useLobbyRedirect() {
const router = useRouter();
useEffect(() => {
const current = getCurrentVoyage();
if (current && current.status === 'in_progress') {
router.replace('/flight');
}
}, [router]);
}

View File

@@ -0,0 +1 @@
export { ConstellationScene } from './ui/ConstellationScene';

View File

@@ -0,0 +1,107 @@
export type LobbyStar = {
cx: number;
cy: number;
r: number;
armScale?: number;
};
export type LobbySegment = {
x1: number;
y1: number;
x2: number;
y2: number;
};
export type LobbyConstellation = {
key: "orion" | "auriga" | "ursaMajor";
className: string;
viewBox: string;
colorClass: string;
stars: LobbyStar[];
segments: LobbySegment[];
};
export const LOBBY_CONSTELLATIONS: LobbyConstellation[] = [
{
key: "orion",
className: "absolute bottom-10 left-5 h-72 w-72 opacity-38",
viewBox: "0 0 100 100",
colorClass: "text-[#ffffff]",
stars: [
{ cx: 26, cy: 20, r: 1.2, armScale: 1.2 },
{ cx: 65, cy: 22, r: 0.82 },
{ cx: 45, cy: 53, r: 0.58 },
{ cx: 50, cy: 50, r: 0.54 },
{ cx: 55, cy: 47, r: 0.6 },
{ cx: 38, cy: 84, r: 0.86 },
{ cx: 70, cy: 82, r: 1.1, armScale: 1.2 },
],
segments: [
{ x1: 26, y1: 20, x2: 45, y2: 53 },
{ x1: 65, y1: 22, x2: 55, y2: 47 },
{ x1: 45, y1: 53, x2: 50, y2: 50 },
{ x1: 50, y1: 50, x2: 55, y2: 47 },
{ x1: 45, y1: 53, x2: 38, y2: 84 },
{ x1: 55, y1: 47, x2: 70, y2: 82 },
],
},
{
key: "auriga",
className: "absolute top-10 right-10 h-64 w-64 opacity-34",
viewBox: "0 0 100 100",
colorClass: "text-[#ffffff]",
stars: [
{ cx: 50, cy: 15, r: 1.06, armScale: 1.1 },
{ cx: 20, cy: 35, r: 0.67 },
{ cx: 25, cy: 75, r: 0.65 },
{ cx: 75, cy: 75, r: 0.66 },
{ cx: 85, cy: 35, r: 0.76 },
],
segments: [
{ x1: 50, y1: 15, x2: 20, y2: 35 },
{ x1: 20, y1: 35, x2: 25, y2: 75 },
{ x1: 25, y1: 75, x2: 75, y2: 75 },
{ x1: 75, y1: 75, x2: 85, y2: 35 },
{ x1: 85, y1: 35, x2: 50, y2: 15 },
],
},
{
key: "ursaMajor",
className: "absolute top-20 left-10 h-48 w-80 opacity-34",
viewBox: "0 0 100 60",
colorClass: "text-[#ffffff]",
stars: [
{ cx: 8, cy: 12, r: 0.64 },
{ cx: 32, cy: 12, r: 0.67 },
{ cx: 46, cy: 24, r: 0.69 },
{ cx: 58, cy: 30, r: 0.99 },
{ cx: 88, cy: 36, r: 0.98 },
{ cx: 78, cy: 48, r: 0.93 },
{ cx: 58, cy: 42, r: 0.96 },
],
segments: [
{ x1: 8, y1: 12, x2: 32, y2: 12 },
{ x1: 32, y1: 12, x2: 46, y2: 24 },
{ x1: 46, y1: 24, x2: 58, y2: 30 },
{ x1: 58, y1: 30, x2: 88, y2: 36 },
{ x1: 88, y1: 36, x2: 78, y2: 48 },
{ x1: 78, y1: 48, x2: 58, y2: 42 },
{ x1: 58, y1: 42, x2: 58, y2: 30 },
],
},
];
export const LOBBY_STAR_TIMINGS = [
{ duration: 2.3, delay: 0.1 },
{ duration: 3.1, delay: 1.2 },
{ duration: 4.8, delay: 0.8 },
{ duration: 2.7, delay: 2.1 },
{ duration: 5.2, delay: 1.7 },
{ duration: 3.9, delay: 0.4 },
{ duration: 4.4, delay: 2.6 },
{ duration: 2.1, delay: 1.3 },
{ duration: 5.8, delay: 0.2 },
{ duration: 3.3, delay: 2.4 },
{ duration: 4.0, delay: 1.1 },
{ duration: 2.9, delay: 1.9 },
] as const;

View File

@@ -0,0 +1,49 @@
import { LOBBY_CONSTELLATIONS } from '@/features/lobby-starfield/model/constellationData';
import { StarGlint } from './StarGlint';
export function ConstellationScene() {
return (
<>
{LOBBY_CONSTELLATIONS.map((constellation, constellationIndex) => {
const starIndexOffset = LOBBY_CONSTELLATIONS.slice(0, constellationIndex).reduce(
(sum, item) => sum + item.stars.length,
0,
);
return (
<div key={constellation.key} className={constellation.className}>
<svg
viewBox={constellation.viewBox}
className={`h-full w-full fill-current ${constellation.colorClass}`}
>
{constellation.segments.map((segment, segmentIndex) => (
<line
key={`${constellation.key}-segment-${segmentIndex}`}
x1={segment.x1}
y1={segment.y1}
x2={segment.x2}
y2={segment.y2}
stroke="currentColor"
strokeWidth="0.2"
className="opacity-20"
/>
))}
{constellation.stars.map((star, starIndex) => {
const globalStarIndex = starIndexOffset + starIndex;
return (
<StarGlint
key={`${constellation.key}-star-${globalStarIndex}`}
starIndex={globalStarIndex}
star={star}
/>
);
})}
</svg>
</div>
);
})}
</>
);
}

View File

@@ -0,0 +1,133 @@
import { CSSProperties } from "react";
import {
LOBBY_STAR_TIMINGS,
LobbyStar,
} from "@/features/lobby-starfield/model/constellationData";
export function StarGlint({
star,
starIndex,
}: {
star: LobbyStar;
starIndex: number;
}) {
const timing = LOBBY_STAR_TIMINGS[starIndex % LOBBY_STAR_TIMINGS.length];
const coreRadius = star.r * 0.5;
const strengthTier =
coreRadius >= 0.9 ? "bright" : coreRadius >= 0.7 ? "mid" : "faint";
const glintPeak =
strengthTier === "bright" ? 0.54 : strengthTier === "mid" ? 0.47 : 0.41;
const bloomPeak =
strengthTier === "bright" ? 0.085 : strengthTier === "mid" ? 0.065 : 0.045;
const coreLow =
strengthTier === "bright" ? 0.88 : strengthTier === "mid" ? 0.73 : 0.6;
const coreHigh =
strengthTier === "bright" ? 0.98 : strengthTier === "mid" ? 0.89 : 0.81;
const coreReduced =
strengthTier === "bright" ? 0.88 : strengthTier === "mid" ? 0.75 : 0.64;
const coreStyle = {
animationDuration: `${timing.duration}s`,
animationDelay: `${timing.delay}s`,
"--core-low": `${coreLow}`,
"--core-high": `${coreHigh}`,
"--core-reduced": `${coreReduced}`,
} as CSSProperties;
const glintStyle = {
animationDuration: `${timing.duration}s`,
animationDelay: `${timing.delay + 0.12}s`,
"--glint-peak": `${glintPeak}`,
"--glint-base": "0.02",
"--bloom-peak": `${bloomPeak}`,
"--bloom-base": "0.01",
} as CSSProperties;
const glintLength = coreRadius * 5.2 * (star.armScale ?? 1);
const gradientXId = `glint-x-${starIndex}`;
const gradientYId = `glint-y-${starIndex}`;
return (
<g>
<defs>
<linearGradient
id={gradientXId}
x1={star.cx - glintLength}
y1={star.cy}
x2={star.cx + glintLength}
y2={star.cy}
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
<stop offset="50%" stopColor="currentColor" stopOpacity="1" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
<linearGradient
id={gradientYId}
x1={star.cx}
y1={star.cy - glintLength}
x2={star.cx}
y2={star.cy + glintLength}
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
<stop offset="50%" stopColor="currentColor" stopOpacity="1" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
<circle
cx={star.cx}
cy={star.cy}
r={coreRadius}
className="star-core"
style={coreStyle}
/>
<circle
cx={star.cx}
cy={star.cy}
r={coreRadius * 1.55}
className="star-core-bloom"
style={coreStyle}
/>
<line
x1={star.cx - glintLength}
y1={star.cy}
x2={star.cx + glintLength}
y2={star.cy}
className="star-glint-bloom"
stroke={`url(#${gradientXId})`}
style={glintStyle}
/>
<line
x1={star.cx}
y1={star.cy - glintLength}
x2={star.cx}
y2={star.cy + glintLength}
className="star-glint-bloom"
stroke={`url(#${gradientYId})`}
style={glintStyle}
/>
<line
x1={star.cx - glintLength}
y1={star.cy}
x2={star.cx + glintLength}
y2={star.cy}
className="star-glint"
stroke={`url(#${gradientXId})`}
style={glintStyle}
/>
<line
x1={star.cx}
y1={star.cy - glintLength}
x2={star.cx}
y2={star.cy + glintLength}
className="star-glint"
stroke={`url(#${gradientYId})`}
style={glintStyle}
/>
</g>
);
}

View File

@@ -0,0 +1,62 @@
export const FEATURE_FLAGS = {
crewPresencePanelEnabled: false,
crewPresenceNotificationsEnabled: false,
} as const;
export const CREW_PRESENCE_ROLLOUT_GUARDRAILS = {
forcePanelOff: false,
forceNotificationsOff: false,
reduceRenderCap: false,
relaxUpdateInterval: false,
reducedRenderCap: 30,
defaultRenderCap: 50,
defaultUpdateIntervalMs: 30_000,
relaxedUpdateIntervalMs: 60_000,
} as const;
export const CREW_PRESENCE_ROLLBACK_TOGGLE_BINDINGS = {
forcePanelOff: "rollback.panel_off",
forceNotificationsOff: "rollback.notifications_off",
reduceRenderCap: "rollback.cap_reduce",
relaxUpdateInterval: "rollback.interval_relax",
} as const;
export type CrewPresenceRuntimeConfig = {
panelEnabled: boolean;
notificationsEnabled: boolean;
renderCap: number;
updateIntervalMs: number;
};
export const resolveCrewPresenceRuntimeConfig = (): CrewPresenceRuntimeConfig => {
const panelEnabled =
FEATURE_FLAGS.crewPresencePanelEnabled &&
!CREW_PRESENCE_ROLLOUT_GUARDRAILS.forcePanelOff;
const notificationsEnabled =
FEATURE_FLAGS.crewPresenceNotificationsEnabled &&
!CREW_PRESENCE_ROLLOUT_GUARDRAILS.forceNotificationsOff;
const renderCap = CREW_PRESENCE_ROLLOUT_GUARDRAILS.reduceRenderCap
? CREW_PRESENCE_ROLLOUT_GUARDRAILS.reducedRenderCap
: CREW_PRESENCE_ROLLOUT_GUARDRAILS.defaultRenderCap;
const updateIntervalMs = CREW_PRESENCE_ROLLOUT_GUARDRAILS.relaxUpdateInterval
? CREW_PRESENCE_ROLLOUT_GUARDRAILS.relaxedUpdateIntervalMs
: CREW_PRESENCE_ROLLOUT_GUARDRAILS.defaultUpdateIntervalMs;
return {
panelEnabled,
notificationsEnabled,
renderCap,
updateIntervalMs,
};
};
export const isCrewPresenceRuntimeConfigSafe = (
config: CrewPresenceRuntimeConfig,
): boolean => config.renderCap > 0 && config.updateIntervalMs >= 1_000;
export const CREW_PRESENCE_QA_GUARDRAIL_CHECKS = [
"panel_toggle_guard",
"notifications_toggle_guard",
"render_cap_guard",
"update_interval_guard",
] as const;

555
src/shared/config/i18n.ts Normal file
View File

@@ -0,0 +1,555 @@
import { VoyageStatus } from "@/shared/types";
export const SUPPORTED_LOCALES = ["ko", "en", "ja", "fr", "de"] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "en";
export const MANUAL_LOCALE_STORAGE_KEY = "focustella_locale_manual_v1";
export const LOCALE_LABELS: Record<Locale, string> = {
ko: "한국어",
en: "English",
ja: "日本語",
fr: "Français",
de: "Deutsch",
};
const enMessages = {
"layout.nav.log": "Logbook",
"layout.nav.settings": "Settings",
"layout.nav.language": "Language",
"common.loading": "Loading...",
"common.minuteShort": "min",
"routes.station.name": "Space Station",
"routes.station.tag": "Wait/Flexible",
"routes.station.description": "A safe zone you can stay in without time limits",
"routes.orion.name": "Orion",
"routes.orion.tag": "Deep Work",
"routes.orion.description": "60-minute focus voyage",
"routes.gemini.name": "Gemini",
"routes.gemini.tag": "Short Sprint",
"routes.gemini.description": "30-minute focus voyage",
"lobby.title": "Which constellation will you sail to?",
"lobby.subtitle": "Choose an orbit that helps your focus.",
"lobby.cta.station": "Enter Station (Wait)",
"lobby.cta.launch": "Launch Now",
"lobby.modal.boardingCheck": "Boarding Check",
"lobby.modal.routeBoarding": "{routeName} Route Boarding",
"lobby.modal.description": "Set your mission before starting this voyage.",
"boarding.check": "Boarding Check",
"boarding.routeBoarding": "{routeName} Route Boarding",
"boarding.missionLabel": "Core mission for this voyage",
"boarding.missionPlaceholder": "Optional: e.g. Finish 3 intro paragraphs",
"boarding.optionalLabel": "Optional",
"boarding.cancel": "Cancel",
"boarding.submit": "Dock Complete (Launch)",
"flight.badge.paused": "Paused",
"flight.badge.cruising": "Cruising",
"flight.missionLabel": "Voyage Mission",
"flight.pause": "Pause",
"flight.resume": "Resume",
"flight.crewPresence.header": "Sailing together now: {count}",
"flight.crewPresence.expand": "Expand",
"flight.crewPresence.collapse": "Collapse",
"flight.crewPresence.listLimit": "Showing up to {limit} crew",
"flight.crewPresence.overflow": "+{count} more",
"flight.crewPresence.status.online": "Online",
"flight.crewPresence.status.idle": "Idle",
"flight.crewPresence.status.offline": "Offline",
"flight.crewPresence.activityHint": "Based on recent activity",
"flight.crewPresence.elapsedMinutes": "{minutes}m ago",
"flight.crewPresence.goal.private": "Goal private",
"flight.crewPresence.goal.unset": "Goal not set",
"flight.crewPresence.notifications.policyOn": "Alerts ON",
"flight.crewPresence.notifications.filterHint":
"Showing friend/my-crew events only",
"flight.crewPresence.notifications.target.friend": "Friend",
"flight.crewPresence.notifications.target.myCrew": "My crew",
"flight.finish.debrief": "Arrived (Debrief)",
"flight.finish.end": "End Voyage",
"flight.debrief.title": "Wrap up this voyage",
"flight.debrief.description": "Write a short note and save it to your logbook.",
"debrief.page.title": "You safely reached orbit",
"debrief.page.description": "Record this voyage briefly and wrap up.",
"debrief.status.label": "Voyage Result",
"debrief.option.completed.label": "Completed the mission",
"debrief.option.completed.desc": "I finished what I set out to do.",
"debrief.option.partial.label": "Made partial progress",
"debrief.option.partial.desc": "I advanced key parts and left next steps.",
"debrief.option.reoriented.label": "Redefined the mission",
"debrief.option.reoriented.desc": "I reset scope and priorities while working.",
"debrief.reflection.label": "Reflection after this voyage",
"debrief.reflection.placeholder":
"e.g. What worked, what did not, and what I learned",
"debrief.save": "Save to Logbook",
"log.title": "My Voyage Logs",
"log.mission.empty": "No mission entered",
"log.empty": "No voyages recorded yet.",
"log.firstVoyage": "Start your first voyage",
"log.detail.back": "Back to list",
"log.detail.loadingOrNotFound": "Loading or Not Found...",
"log.detail.statusTitle": "Result Status",
"log.detail.progressTitle": "What I secured",
"log.detail.nextActionTitle": "Next Action",
"log.detail.initialNoteTitle": "Initial Note",
"settings.title": "Settings",
"settings.hideSeconds.title": "Hide Seconds",
"settings.hideSeconds.description":
"Show only minutes on the timer to reduce pressure.",
"status.completed": "Completed",
"status.partial": "Partial",
"status.reoriented": "Reoriented",
"status.aborted": "Aborted Early",
"status.in_progress": "In Progress",
} satisfies Record<string, string>;
type MessageKey = keyof typeof enMessages;
type I18nMessages = Record<MessageKey, string>;
const koMessages = {
"layout.nav.log": "항해일지",
"layout.nav.settings": "설정",
"layout.nav.language": "언어",
"common.loading": "로딩 중...",
"common.minuteShort": "분",
"routes.station.name": "우주정거장",
"routes.station.tag": "대기/자유",
"routes.station.description": "시간 제한 없이 머무를 수 있는 안전지대",
"routes.orion.name": "오리온",
"routes.orion.tag": "딥워크",
"routes.orion.description": "60분 집중 항해",
"routes.gemini.name": "쌍둥이자리",
"routes.gemini.tag": "숏스프린트",
"routes.gemini.description": "30분 집중 항해",
"lobby.title": "어느 별자리로 출항할까요?",
"lobby.subtitle": "몰입하기 좋은 궤도입니다.",
"lobby.cta.station": "정거장 진입 (대기)",
"lobby.cta.launch": "바로 출항",
"lobby.modal.boardingCheck": "Boarding Check",
"lobby.modal.routeBoarding": "{routeName} 항로 탑승",
"lobby.modal.description": "항해를 시작하기 전에 목표를 설정하세요.",
"boarding.check": "Boarding Check",
"boarding.routeBoarding": "{routeName} 항로 탑승",
"boarding.missionLabel": "이번 항해의 핵심 목표",
"boarding.missionPlaceholder": "선택: 예) 서론 3문단 완성하기",
"boarding.optionalLabel": "선택",
"boarding.cancel": "취소",
"boarding.submit": "도킹 완료 (출항)",
"flight.badge.paused": "일시정지",
"flight.badge.cruising": "순항 중",
"flight.missionLabel": "이번 항해 목표",
"flight.pause": "일시정지",
"flight.resume": "다시 시작",
"flight.crewPresence.header": "현재 함께 항해 중 {count}명",
"flight.crewPresence.expand": "펼치기",
"flight.crewPresence.collapse": "접기",
"flight.crewPresence.listLimit": "최대 {limit}명까지 표시",
"flight.crewPresence.overflow": "+{count}명",
"flight.crewPresence.status.online": "온라인",
"flight.crewPresence.status.idle": "자리비움",
"flight.crewPresence.status.offline": "오프라인",
"flight.crewPresence.activityHint": "최근 활동 기반",
"flight.crewPresence.elapsedMinutes": "{minutes}분 전",
"flight.crewPresence.goal.private": "목표 비공개",
"flight.crewPresence.goal.unset": "목표 미설정",
"flight.crewPresence.notifications.policyOn": "알림 ON",
"flight.crewPresence.notifications.filterHint":
"friend/my-crew 이벤트만 표시",
"flight.crewPresence.notifications.target.friend": "친구",
"flight.crewPresence.notifications.target.myCrew": "내 크루",
"flight.finish.debrief": "도착 (회고)",
"flight.finish.end": "항해 종료",
"flight.debrief.title": "이번 항해를 정리하세요",
"flight.debrief.description": "짧게 기록하고 항해일지에 저장합니다.",
"debrief.page.title": "무사히 궤도에 도착했습니다",
"debrief.page.description": "이번 항해를 짧게 기록하고 마무리하세요.",
"debrief.status.label": "항해 결과",
"debrief.option.completed.label": "목표를 완수했어요",
"debrief.option.completed.desc": "시작할 때 정한 목표를 끝까지 해냈어요",
"debrief.option.partial.label": "일부까지 진행했어요",
"debrief.option.partial.desc": "중요한 부분을 진행했고 다음 단계가 남았어요",
"debrief.option.reoriented.label": "목표를 재정의했어요",
"debrief.option.reoriented.desc":
"진행 중 목표 범위와 우선순위를 다시 정했어요",
"debrief.reflection.label": "이번 항해를 마치고 느낀 점",
"debrief.reflection.placeholder":
"예: 잘한 점, 아쉬운 점, 느낀 점을 짧게 남겨보세요",
"debrief.save": "항해일지 저장",
"log.title": "나의 항해 기록",
"log.mission.empty": "미입력",
"log.empty": "아직 기록된 항해가 없습니다.",
"log.firstVoyage": "첫 항해 떠나기",
"log.detail.back": "목록으로",
"log.detail.loadingOrNotFound": "로딩 중이거나 기록이 없습니다.",
"log.detail.statusTitle": "결과 상태",
"log.detail.progressTitle": "확보한 것",
"log.detail.nextActionTitle": "다음 행동",
"log.detail.initialNoteTitle": "초기 메모",
"settings.title": "설정",
"settings.hideSeconds.title": "초 단위 숨기기",
"settings.hideSeconds.description":
"타이머에서 분 단위만 표시하여 불안감을 줄입니다.",
"status.completed": "✅ 계획대로",
"status.partial": "🌓 부분 진행",
"status.reoriented": "🧭 방향 재설정",
"status.aborted": "🚨 조기 귀환",
"status.in_progress": "진행 중",
} satisfies I18nMessages;
const jaMessages = {
"layout.nav.log": "航海ログ",
"layout.nav.settings": "設定",
"layout.nav.language": "言語",
"common.loading": "読み込み中...",
"common.minuteShort": "分",
"routes.station.name": "宇宙ステーション",
"routes.station.tag": "待機/自由",
"routes.station.description": "時間制限なしで滞在できる安全地帯",
"routes.orion.name": "オリオン",
"routes.orion.tag": "ディープワーク",
"routes.orion.description": "60分集中航海",
"routes.gemini.name": "ふたご座",
"routes.gemini.tag": "ショートスプリント",
"routes.gemini.description": "30分集中航海",
"lobby.title": "どの星座へ航海しますか?",
"lobby.subtitle": "集中しやすい軌道を選びましょう。",
"lobby.cta.station": "ステーションへ入る(待機)",
"lobby.cta.launch": "今すぐ出航",
"lobby.modal.boardingCheck": "搭乗チェック",
"lobby.modal.routeBoarding": "{routeName} 航路に搭乗",
"lobby.modal.description": "航海を始める前に目標を設定してください。",
"boarding.check": "搭乗チェック",
"boarding.routeBoarding": "{routeName} 航路に搭乗",
"boarding.missionLabel": "今回の航海のコア目標",
"boarding.missionPlaceholder": "任意: 例) 導入の3段落を完成する",
"boarding.optionalLabel": "任意",
"boarding.cancel": "キャンセル",
"boarding.submit": "ドッキング完了(出航)",
"flight.badge.paused": "一時停止",
"flight.badge.cruising": "巡航中",
"flight.missionLabel": "今回の航海目標",
"flight.pause": "一時停止",
"flight.resume": "再開",
"flight.crewPresence.header": "現在一緒に航海中 {count}人",
"flight.crewPresence.expand": "展開",
"flight.crewPresence.collapse": "折りたたむ",
"flight.crewPresence.listLimit": "最大{limit}人まで表示",
"flight.crewPresence.overflow": "+{count}人",
"flight.crewPresence.status.online": "オンライン",
"flight.crewPresence.status.idle": "離席",
"flight.crewPresence.status.offline": "オフライン",
"flight.crewPresence.activityHint": "最近のアクティビティに基づく",
"flight.crewPresence.elapsedMinutes": "{minutes}分前",
"flight.crewPresence.goal.private": "目標は非公開",
"flight.crewPresence.goal.unset": "目標未設定",
"flight.crewPresence.notifications.policyOn": "通知 ON",
"flight.crewPresence.notifications.filterHint":
"friend/my-crew イベントのみ表示",
"flight.crewPresence.notifications.target.friend": "フレンド",
"flight.crewPresence.notifications.target.myCrew": "マイクルー",
"flight.finish.debrief": "到着(振り返り)",
"flight.finish.end": "航海終了",
"flight.debrief.title": "今回の航海を整理しましょう",
"flight.debrief.description": "短く記録して航海ログに保存します。",
"debrief.page.title": "無事に軌道へ到着しました",
"debrief.page.description": "今回の航海を短く記録して締めくくりましょう。",
"debrief.status.label": "航海結果",
"debrief.option.completed.label": "目標を達成しました",
"debrief.option.completed.desc": "開始時に決めた目標を最後まで完了しました。",
"debrief.option.partial.label": "一部まで進めました",
"debrief.option.partial.desc": "重要な部分を進め、次の段階が残っています。",
"debrief.option.reoriented.label": "目標を再定義しました",
"debrief.option.reoriented.desc":
"進行中に目標範囲と優先順位を見直しました。",
"debrief.reflection.label": "今回の航海を終えて感じたこと",
"debrief.reflection.placeholder":
"例: 良かった点・難しかった点・気づきを短く残してください",
"debrief.save": "航海ログに保存",
"log.title": "私の航海記録",
"log.mission.empty": "未入力",
"log.empty": "まだ記録された航海がありません。",
"log.firstVoyage": "最初の航海を始める",
"log.detail.back": "一覧へ戻る",
"log.detail.loadingOrNotFound": "読み込み中、または記録が見つかりません。",
"log.detail.statusTitle": "結果ステータス",
"log.detail.progressTitle": "確保できたこと",
"log.detail.nextActionTitle": "次の行動",
"log.detail.initialNoteTitle": "初期メモ",
"settings.title": "設定",
"settings.hideSeconds.title": "秒表示を隠す",
"settings.hideSeconds.description":
"タイマーを分表示のみにしてプレッシャーを減らします。",
"status.completed": "✅ 計画どおり",
"status.partial": "🌓 一部進行",
"status.reoriented": "🧭 方針再設定",
"status.aborted": "🚨 早期帰還",
"status.in_progress": "進行中",
} satisfies I18nMessages;
const normalizedEnMessages: I18nMessages = enMessages;
const frMessages: I18nMessages = {
"layout.nav.log": "Journal de bord",
"layout.nav.settings": "Parametres",
"layout.nav.language": "Langue",
"common.loading": "Chargement...",
"common.minuteShort": "min",
"routes.station.name": "Station spatiale",
"routes.station.tag": "Attente/Flexible",
"routes.station.description":
"Une zone sure ou vous pouvez rester sans limite de temps",
"routes.orion.name": "Orion",
"routes.orion.tag": "Travail profond",
"routes.orion.description": "Voyage de concentration de 60 minutes",
"routes.gemini.name": "Gemini",
"routes.gemini.tag": "Sprint court",
"routes.gemini.description": "Voyage de concentration de 30 minutes",
"lobby.title": "Vers quelle constellation voulez-vous naviguer ?",
"lobby.subtitle": "Choisissez une orbite qui favorise votre concentration.",
"lobby.cta.station": "Entrer dans la station (Attente)",
"lobby.cta.launch": "Decoller maintenant",
"lobby.modal.boardingCheck": "Verification d'embarquement",
"lobby.modal.routeBoarding": "Embarquement route {routeName}",
"lobby.modal.description":
"Definissez votre mission avant de commencer ce voyage.",
"boarding.check": "Verification d'embarquement",
"boarding.routeBoarding": "Embarquement route {routeName}",
"boarding.missionLabel": "Mission principale pour ce voyage",
"boarding.missionPlaceholder":
"Facultatif: terminer 3 paragraphes d'introduction",
"boarding.optionalLabel": "Facultatif",
"boarding.cancel": "Annuler",
"boarding.submit": "Amarrage termine (Decoller)",
"flight.badge.paused": "En pause",
"flight.badge.cruising": "En croisiere",
"flight.missionLabel": "Mission du voyage",
"flight.pause": "Pause",
"flight.resume": "Reprendre",
"flight.crewPresence.header": "En navigation ensemble actuellement : {count}",
"flight.crewPresence.expand": "Afficher",
"flight.crewPresence.collapse": "Reduire",
"flight.crewPresence.listLimit": "Afficher jusqu'a {limit} membres",
"flight.crewPresence.overflow": "+{count} personnes",
"flight.crewPresence.status.online": "En ligne",
"flight.crewPresence.status.idle": "Inactif",
"flight.crewPresence.status.offline": "Hors ligne",
"flight.crewPresence.activityHint": "Base sur l'activite recente",
"flight.crewPresence.elapsedMinutes": "il y a {minutes} min",
"flight.crewPresence.goal.private": "Objectif prive",
"flight.crewPresence.goal.unset": "Objectif non defini",
"flight.crewPresence.notifications.policyOn": "Alertes ON",
"flight.crewPresence.notifications.filterHint":
"Afficher uniquement les evenements friend/my-crew",
"flight.crewPresence.notifications.target.friend": "Ami",
"flight.crewPresence.notifications.target.myCrew": "Mon crew",
"flight.finish.debrief": "Arrivee (Debrief)",
"flight.finish.end": "Terminer le voyage",
"flight.debrief.title": "Concluez ce voyage",
"flight.debrief.description":
"Ecrivez une courte note et enregistrez-la dans votre journal.",
"debrief.page.title": "Vous etes arrive en orbite en securite",
"debrief.page.description":
"Consignez brievement ce voyage puis terminez.",
"debrief.status.label": "Resultat du voyage",
"debrief.option.completed.label": "Mission accomplie",
"debrief.option.completed.desc":
"J'ai termine ce que j'avais prevu de faire.",
"debrief.option.partial.label": "Progression partielle",
"debrief.option.partial.desc":
"J'ai avance les points cles et garde les prochaines etapes.",
"debrief.option.reoriented.label": "Mission redefinie",
"debrief.option.reoriented.desc":
"J'ai reajuste le perimetre et les priorites pendant le travail.",
"debrief.reflection.label": "Reflexion apres ce voyage",
"debrief.reflection.placeholder":
"Exemple: ce qui a fonctionne, ce qui n'a pas marche, et ce que j'ai appris",
"debrief.save": "Enregistrer dans le journal",
"log.title": "Mes journaux de voyage",
"log.mission.empty": "Mission non renseignee",
"log.empty": "Aucun voyage enregistre pour le moment.",
"log.firstVoyage": "Commencer votre premier voyage",
"log.detail.back": "Retour a la liste",
"log.detail.loadingOrNotFound": "Chargement ou introuvable...",
"log.detail.statusTitle": "Statut du resultat",
"log.detail.progressTitle": "Ce que j'ai obtenu",
"log.detail.nextActionTitle": "Action suivante",
"log.detail.initialNoteTitle": "Note initiale",
"settings.title": "Parametres",
"settings.hideSeconds.title": "Masquer les secondes",
"settings.hideSeconds.description":
"Afficher uniquement les minutes sur le minuteur pour reduire la pression.",
"status.completed": "Termine",
"status.partial": "Partiel",
"status.reoriented": "Reoriente",
"status.aborted": "Interrompu tot",
"status.in_progress": "En cours",
};
const deMessages: I18nMessages = {
"layout.nav.log": "Logbuch",
"layout.nav.settings": "Einstellungen",
"layout.nav.language": "Sprache",
"common.loading": "Wird geladen...",
"common.minuteShort": "Min",
"routes.station.name": "Raumstation",
"routes.station.tag": "Warten/Flexibel",
"routes.station.description":
"Ein sicherer Bereich, in dem Sie ohne Zeitlimit bleiben konnen",
"routes.orion.name": "Orion",
"routes.orion.tag": "Tiefe Arbeit",
"routes.orion.description": "60-Minuten-Fokusreise",
"routes.gemini.name": "Gemini",
"routes.gemini.tag": "Kurzer Sprint",
"routes.gemini.description": "30-Minuten-Fokusreise",
"lobby.title": "Zu welcher Konstellation mochten Sie reisen?",
"lobby.subtitle":
"Wahlen Sie eine Umlaufbahn, die Ihre Konzentration unterstutzt.",
"lobby.cta.station": "Station betreten (Warten)",
"lobby.cta.launch": "Jetzt starten",
"lobby.modal.boardingCheck": "Boarding-Check",
"lobby.modal.routeBoarding": "Boarding fur Route {routeName}",
"lobby.modal.description":
"Legen Sie Ihre Mission fest, bevor diese Reise beginnt.",
"boarding.check": "Boarding-Check",
"boarding.routeBoarding": "Boarding fur Route {routeName}",
"boarding.missionLabel": "Kernmission fur diese Reise",
"boarding.missionPlaceholder":
"Optional: 3 Einleitungsabsatze fertigstellen",
"boarding.optionalLabel": "Optional",
"boarding.cancel": "Abbrechen",
"boarding.submit": "Andocken abgeschlossen (Start)",
"flight.badge.paused": "Pausiert",
"flight.badge.cruising": "Im Flug",
"flight.missionLabel": "Reisemission",
"flight.pause": "Pause",
"flight.resume": "Fortsetzen",
"flight.crewPresence.header": "Aktuell gemeinsam auf Reise: {count}",
"flight.crewPresence.expand": "Ausklappen",
"flight.crewPresence.collapse": "Einklappen",
"flight.crewPresence.listLimit": "Bis zu {limit} Crewmitglieder anzeigen",
"flight.crewPresence.overflow": "+{count} mehr",
"flight.crewPresence.status.online": "Online",
"flight.crewPresence.status.idle": "Inaktiv",
"flight.crewPresence.status.offline": "Offline",
"flight.crewPresence.activityHint": "Basierend auf letzter Aktivitat",
"flight.crewPresence.elapsedMinutes": "vor {minutes} Min",
"flight.crewPresence.goal.private": "Ziel privat",
"flight.crewPresence.goal.unset": "Ziel nicht festgelegt",
"flight.crewPresence.notifications.policyOn": "Hinweise ON",
"flight.crewPresence.notifications.filterHint":
"Nur friend/my-crew Ereignisse anzeigen",
"flight.crewPresence.notifications.target.friend": "Freund",
"flight.crewPresence.notifications.target.myCrew": "Meine Crew",
"flight.finish.debrief": "Ankunft (Debrief)",
"flight.finish.end": "Reise beenden",
"flight.debrief.title": "Diese Reise abschliessen",
"flight.debrief.description":
"Schreiben Sie eine kurze Notiz und speichern Sie sie im Logbuch.",
"debrief.page.title": "Sie haben die Umlaufbahn sicher erreicht",
"debrief.page.description":
"Dokumentieren Sie diese Reise kurz und schliessen Sie ab.",
"debrief.status.label": "Reiseergebnis",
"debrief.option.completed.label": "Mission abgeschlossen",
"debrief.option.completed.desc":
"Ich habe das erledigt, was ich mir vorgenommen hatte.",
"debrief.option.partial.label": "Teilweise Fortschritte",
"debrief.option.partial.desc":
"Ich habe wichtige Teile vorangebracht und nachste Schritte offen gelassen.",
"debrief.option.reoriented.label": "Mission neu ausgerichtet",
"debrief.option.reoriented.desc":
"Ich habe Umfang und Prioritaten wahrend der Arbeit neu festgelegt.",
"debrief.reflection.label": "Reflexion nach dieser Reise",
"debrief.reflection.placeholder":
"Beispiel: was gut lief, was nicht gut lief, und was ich gelernt habe",
"debrief.save": "Im Logbuch speichern",
"log.title": "Meine Reiseprotokolle",
"log.mission.empty": "Keine Mission eingetragen",
"log.empty": "Noch keine Reisen aufgezeichnet.",
"log.firstVoyage": "Ihre erste Reise starten",
"log.detail.back": "Zuruck zur Liste",
"log.detail.loadingOrNotFound": "Wird geladen oder nicht gefunden...",
"log.detail.statusTitle": "Ergebnisstatus",
"log.detail.progressTitle": "Was ich erreicht habe",
"log.detail.nextActionTitle": "Nachste Aktion",
"log.detail.initialNoteTitle": "Anfangsnotiz",
"settings.title": "Einstellungen",
"settings.hideSeconds.title": "Sekunden ausblenden",
"settings.hideSeconds.description":
"Nur Minuten auf dem Timer anzeigen, um Druck zu reduzieren.",
"status.completed": "Abgeschlossen",
"status.partial": "Teilweise",
"status.reoriented": "Neu ausgerichtet",
"status.aborted": "Fruh abgebrochen",
"status.in_progress": "In Bearbeitung",
};
export const I18N_MESSAGES: Record<Locale, I18nMessages> = {
ko: koMessages,
en: normalizedEnMessages,
ja: jaMessages,
fr: frMessages,
de: deMessages,
};
export type I18nKey = MessageKey;
export type TranslationParams = Record<string, string | number>;
const interpolateMessage = (
template: string,
params?: TranslationParams,
): string => {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, paramKey) => {
const value = params[paramKey];
return value === undefined ? "" : String(value);
});
};
export const translateText = (
locale: Locale,
key: I18nKey | string,
params?: TranslationParams,
fallback = "",
): string => {
const localeMessages = I18N_MESSAGES[locale] as Record<string, string>;
const defaultMessages = I18N_MESSAGES[DEFAULT_LOCALE] as Record<string, string>;
const template =
localeMessages[key] ?? defaultMessages[key] ?? fallback;
return interpolateMessage(template, params);
};
type DebriefStatusOption = "completed" | "partial" | "reoriented";
export const DEBRIEF_STATUS_OPTIONS: Array<{
value: DebriefStatusOption;
labelKey: I18nKey;
descKey: I18nKey;
}> = [
{
value: "completed",
labelKey: "debrief.option.completed.label",
descKey: "debrief.option.completed.desc",
},
{
value: "partial",
labelKey: "debrief.option.partial.label",
descKey: "debrief.option.partial.desc",
},
{
value: "reoriented",
labelKey: "debrief.option.reoriented.label",
descKey: "debrief.option.reoriented.desc",
},
];
export const VOYAGE_STATUS_LABEL_KEYS: Record<VoyageStatus, I18nKey> = {
completed: "status.completed",
partial: "status.partial",
reoriented: "status.reoriented",
aborted: "status.aborted",
in_progress: "status.in_progress",
};

View File

@@ -0,0 +1,28 @@
import { Route } from '@/shared/types';
export const ROUTES: Route[] = [
{
id: 'station',
durationMinutes: 0,
nameKey: 'routes.station.name',
tagKey: 'routes.station.tag',
descriptionKey: 'routes.station.description',
},
{
id: 'orion',
durationMinutes: 60,
nameKey: 'routes.orion.name',
tagKey: 'routes.orion.tag',
descriptionKey: 'routes.orion.description',
},
{
id: 'gemini',
durationMinutes: 30,
nameKey: 'routes.gemini.name',
tagKey: 'routes.gemini.tag',
descriptionKey: 'routes.gemini.description',
},
];
export const findRouteById = (routeId: string) =>
ROUTES.find((route) => route.id === routeId);

View File

@@ -0,0 +1,54 @@
export const FLIGHT_STARFIELD_TUNING = {
mobileBreakpoint: 768,
densityDivisor: 42000,
densityMultiplier: 1.35,
speedScale: 0.3,
starCount: {
mobile: { min: 16, max: 48 },
desktop: { min: 24, max: 72 },
},
maxStars: {
mobile: 48,
desktop: 72,
},
vanishXJitter: { min: 10, max: 25 },
speedTiers: {
slow: { chance: 0.9, min: 0.00255, max: 0.00595 },
medium: { chance: 0.99, min: 0.00595, max: 0.00935 },
fast: { min: 0.00935, max: 0.0119 },
},
tail: {
pointChance: 0.82,
shortChance: 0.86,
pointRange: { min: 0.5, max: 2.5 },
shortRange: { min: 2.5, max: 3.8 },
longRange: { min: 4, max: 10 },
cleanup: {
minAlphaToDraw: 0.08,
minMovementToDraw: 0.12,
minLineWidthToDraw: 0.86,
minTailLengthToDraw: 1,
},
},
spawnRadius: {
centerChance: 0.08,
ringChance: 0.8,
centerRange: { min: 0, max: 60 },
ringRange: { min: 80, max: 320 },
wideRange: { min: 120, maxScaleOfViewport: 0.7, maxAbsolute: 360 },
},
zRange: {
initial: { min: 0.55, max: 1.6 },
respawn: { min: 0.9, max: 1.55 },
recycleThreshold: 0.22,
},
radius: {
normal: { min: 0.7, max: 1.2 },
highlight: { min: 1.2, max: 1.8 },
highlightChance: 0.16,
},
alpha: {
normal: { min: 0.25, max: 0.55 },
highlight: { min: 0.55, max: 0.85 },
},
} as const;

View File

@@ -0,0 +1,71 @@
export type CrewPresenceEventName =
| "panel_visible"
| "panel_collapsed"
| "presence_event_click";
export type CrewPresenceNotificationTarget = "friend" | "my-crew";
export type CrewPresenceEventPayload = {
decisionId: string;
routeId: string;
totalCount: number;
visibleCount: number;
renderCap?: number;
updateIntervalMs?: number;
collapsed?: boolean;
targetType?: CrewPresenceNotificationTarget;
};
type CrewPresenceDashboardBinding = {
metricKey: string;
chartKey: string;
};
export const CREW_PRESENCE_DASHBOARD_BINDINGS: Record<
CrewPresenceEventName,
CrewPresenceDashboardBinding
> = {
panel_visible: {
metricKey: "crew_presence.panel_visible.count",
chartKey: "crew_presence_panel_visible_trend",
},
panel_collapsed: {
metricKey: "crew_presence.panel_collapsed.count",
chartKey: "crew_presence_panel_collapsed_trend",
},
presence_event_click: {
metricKey: "crew_presence.presence_event_click.count",
chartKey: "crew_presence_event_click_target_split",
},
};
declare global {
interface Window {
__HUSHROOM_ANALYTICS__?: {
track: (eventName: string, payload: Record<string, unknown>) => void;
};
}
}
export const trackCrewPresenceEvent = (
eventName: CrewPresenceEventName,
payload: CrewPresenceEventPayload,
) => {
try {
if (typeof window === "undefined") return;
const dashboardBinding = CREW_PRESENCE_DASHBOARD_BINDINGS[eventName];
window.dispatchEvent(
new CustomEvent("hushroom:crew-presence-analytics", {
detail: { eventName, payload, dashboardBinding },
}),
);
window.__HUSHROOM_ANALYTICS__?.track(eventName, {
...payload,
...dashboardBinding,
});
} catch {
// Keep analytics failures isolated from UX flow.
}
};

6
src/shared/lib/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,5 @@
export const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
export const randomInRange = (min: number, max: number) =>
min + Math.random() * (max - min);

View File

@@ -0,0 +1,4 @@
export const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
export const getPrefersReducedMotionMediaQuery = () =>
window.matchMedia(REDUCED_MOTION_QUERY);

42
src/shared/lib/store.ts Normal file
View File

@@ -0,0 +1,42 @@
import { UserPreferences, Voyage } from '@/shared/types';
const KEYS = {
HISTORY: 'focustella_history_v1',
CURRENT: 'focustella_current_v1',
PREFS: 'focustella_prefs_v1',
};
export const getHistory = (): Voyage[] => {
if (typeof window === 'undefined') return [];
const item = localStorage.getItem(KEYS.HISTORY);
return item ? JSON.parse(item) : [];
};
export const saveToHistory = (voyage: Voyage) => {
const history = getHistory();
localStorage.setItem(KEYS.HISTORY, JSON.stringify([voyage, ...history]));
};
export const getCurrentVoyage = (): Voyage | null => {
if (typeof window === 'undefined') return null;
const item = localStorage.getItem(KEYS.CURRENT);
return item ? JSON.parse(item) : null;
};
export const saveCurrentVoyage = (voyage: Voyage | null) => {
if (voyage === null) {
localStorage.removeItem(KEYS.CURRENT);
} else {
localStorage.setItem(KEYS.CURRENT, JSON.stringify(voyage));
}
};
export const getPreferences = (): UserPreferences => {
if (typeof window === 'undefined') return { hideSeconds: false };
const item = localStorage.getItem(KEYS.PREFS);
return item ? JSON.parse(item) : { hideSeconds: false };
};
export const savePreferences = (prefs: UserPreferences) => {
localStorage.setItem(KEYS.PREFS, JSON.stringify(prefs));
};

33
src/shared/types/index.ts Normal file
View File

@@ -0,0 +1,33 @@
export interface Route {
id: string;
durationMinutes: number;
nameKey: string;
tagKey: string;
descriptionKey: string;
}
export type VoyageStatus =
| 'completed'
| 'partial'
| 'reoriented'
| 'aborted'
| 'in_progress';
export interface Voyage {
id: string;
routeId: string;
routeName: string;
startedAt: number;
endedAt?: number;
durationMinutes: number;
status: VoyageStatus;
missionText: string;
notes?: string;
debriefProgress?: string;
nextAction?: string;
blockerTag?: string;
}
export interface UserPreferences {
hideSeconds: boolean;
}

View File

@@ -0,0 +1 @@
export { FlightBackgroundWidget } from './ui/FlightBackgroundWidget';

View File

@@ -0,0 +1,11 @@
import { FlightStarfieldCanvas } from '@/features/flight-starfield';
export function FlightBackgroundWidget({ isPaused }: { isPaused: boolean }) {
return (
<FlightStarfieldCanvas
vanishYOffset={-68}
centerProtectRadius={200}
isPaused={isPaused}
/>
);
}

View File

@@ -0,0 +1 @@
export { FlightHudWidget } from './ui/FlightHudWidget';

View File

@@ -0,0 +1,609 @@
import { useRouter } from "next/navigation";
import { FormEvent, useEffect, useRef, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useI18n } from "@/features/i18n/model/useI18n";
import {
isCrewPresenceRuntimeConfigSafe,
resolveCrewPresenceRuntimeConfig,
} from "@/shared/config/featureFlags";
import { DEBRIEF_STATUS_OPTIONS } from "@/shared/config/i18n";
import { findRouteById } from "@/shared/config/routes";
import {
CrewPresenceNotificationTarget,
trackCrewPresenceEvent,
} from "@/shared/lib/analytics";
import { saveCurrentVoyage, saveToHistory } from "@/shared/lib/store";
import { Voyage, VoyageStatus } from "@/shared/types";
const FINISH_HOLD_MS = 1000;
const HOLD_STAGE_ONE_MS = 100;
const HOLD_STAGE_ONE_PROGRESS = 0.2;
const CREW_PRESENCE_GUARDRAIL_FALLBACK_CAP = 1;
const CREW_PRESENCE_GUARDRAIL_FALLBACK_INTERVAL_MS = 60_000;
const CREW_PRESENCE_DECISION_ID = "DEC-20260216-01";
type CrewPresenceStatus = "online" | "idle" | "offline";
type CrewGoalVisibility = "public" | "private" | "unset";
type CrewNotificationTarget = "friend" | "my-crew" | "global";
type CrewPresenceMember = {
id: string;
name: string;
status: CrewPresenceStatus;
elapsedMinutes: number;
goalVisibility: CrewGoalVisibility;
goalText: string;
};
type CrewPresenceNotification = {
id: string;
targetType: CrewNotificationTarget;
message: string;
};
const CREW_PRESENCE_STATUS_CYCLE: CrewPresenceStatus[] = [
"online",
"idle",
"offline",
];
const CREW_PRESENCE_GOAL_CYCLE: CrewGoalVisibility[] = [
"public",
"private",
"unset",
];
const CREW_PRESENCE_MOCK_MEMBERS: CrewPresenceMember[] = Array.from(
{ length: 57 },
(_, index) => ({
id: `crew-${index + 1}`,
name: `Crew ${index + 1}`,
status:
CREW_PRESENCE_STATUS_CYCLE[index % CREW_PRESENCE_STATUS_CYCLE.length],
elapsedMinutes: (index + 1) * 2,
goalVisibility:
CREW_PRESENCE_GOAL_CYCLE[index % CREW_PRESENCE_GOAL_CYCLE.length],
goalText: `Mission ${index + 1}`,
}),
);
const CREW_STATUS_LABEL_KEYS: Record<CrewPresenceStatus, string> = {
online: "flight.crewPresence.status.online",
idle: "flight.crewPresence.status.idle",
offline: "flight.crewPresence.status.offline",
};
const CREW_GOAL_STATE_LABEL_KEYS: Record<
Exclude<CrewGoalVisibility, "public">,
string
> = {
private: "flight.crewPresence.goal.private",
unset: "flight.crewPresence.goal.unset",
};
const CREW_NOTIFICATION_ALLOWED_TARGETS = ["friend", "my-crew"] as const;
const CREW_PRESENCE_MOCK_NOTIFICATIONS: CrewPresenceNotification[] = [
{
id: "notif-1",
targetType: "friend",
message: "Crew 2 just joined this voyage",
},
{
id: "notif-2",
targetType: "my-crew",
message: "Crew 7 switched to online",
},
{
id: "notif-3",
targetType: "global",
message: "A public voyage wave started",
},
];
const CREW_NOTIFICATION_TARGET_LABEL_KEYS: Record<
(typeof CREW_NOTIFICATION_ALLOWED_TARGETS)[number],
string
> = {
friend: "flight.crewPresence.notifications.target.friend",
"my-crew": "flight.crewPresence.notifications.target.myCrew",
};
const isCrewNotificationTargetAllowed = (
targetType: CrewNotificationTarget,
): targetType is (typeof CREW_NOTIFICATION_ALLOWED_TARGETS)[number] =>
CREW_NOTIFICATION_ALLOWED_TARGETS.includes(
targetType as (typeof CREW_NOTIFICATION_ALLOWED_TARGETS)[number],
);
type AllowedCrewPresenceNotification = CrewPresenceNotification & {
targetType: CrewPresenceNotificationTarget;
};
type FlightHudWidgetProps = {
voyage: Voyage | null;
isPaused: boolean;
formattedTime: string;
isCountdownCompleted: boolean;
handlePauseToggle: () => void;
handleFinish: () => Voyage | null;
};
export function FlightHudWidget({
voyage,
isPaused,
formattedTime,
isCountdownCompleted,
handlePauseToggle,
handleFinish,
}: FlightHudWidgetProps) {
const { t } = useI18n();
const router = useRouter();
const [isDebriefOpen, setIsDebriefOpen] = useState(false);
const [finishedVoyage, setFinishedVoyage] = useState<Voyage | null>(null);
const [status, setStatus] = useState<VoyageStatus | null>(null);
const [progress, setProgress] = useState("");
const [holdProgress, setHoldProgress] = useState(0);
const [isCrewPanelCollapsed, setIsCrewPanelCollapsed] = useState(true);
const holdStartAtRef = useRef<number | null>(null);
const holdRafRef = useRef<number | null>(null);
const isHoldCompletedRef = useRef(false);
const openDebriefModal = () => {
const endedVoyage = handleFinish();
if (!endedVoyage) return;
setFinishedVoyage(endedVoyage);
setStatus(null);
setProgress("");
setIsDebriefOpen(true);
};
const handleDebriefSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!status || !finishedVoyage) return;
const finalVoyage: Voyage = {
...finishedVoyage,
status,
debriefProgress: progress,
};
saveToHistory(finalVoyage);
saveCurrentVoyage(null);
setIsDebriefOpen(false);
router.push("/log");
};
const stopHoldLoop = () => {
if (holdRafRef.current !== null) {
cancelAnimationFrame(holdRafRef.current);
holdRafRef.current = null;
}
holdStartAtRef.current = null;
};
const resetHold = () => {
stopHoldLoop();
isHoldCompletedRef.current = false;
setHoldProgress(0);
};
const openDebriefByHold = () => {
isHoldCompletedRef.current = true;
stopHoldLoop();
setHoldProgress(1);
requestAnimationFrame(() => {
resetHold();
openDebriefModal();
});
};
const tickHoldProgress = (timestamp: number) => {
if (holdStartAtRef.current === null) return;
const elapsed = timestamp - holdStartAtRef.current;
const nextProgress = (() => {
if (elapsed <= HOLD_STAGE_ONE_MS) {
return Math.min(
HOLD_STAGE_ONE_PROGRESS,
(elapsed / HOLD_STAGE_ONE_MS) * HOLD_STAGE_ONE_PROGRESS,
);
}
const stageTwoElapsed = elapsed - HOLD_STAGE_ONE_MS;
const stageTwoDuration = FINISH_HOLD_MS - HOLD_STAGE_ONE_MS;
const stageTwoProgressRatio = Math.min(
1,
stageTwoElapsed / stageTwoDuration,
);
return Math.min(
1,
HOLD_STAGE_ONE_PROGRESS +
stageTwoProgressRatio * (1 - HOLD_STAGE_ONE_PROGRESS),
);
})();
setHoldProgress(nextProgress);
if (nextProgress >= 1) {
openDebriefByHold();
return;
}
holdRafRef.current = requestAnimationFrame(tickHoldProgress);
};
const startHoldToFinish = () => {
if (isDebriefOpen) return;
resetHold();
holdStartAtRef.current = performance.now();
setHoldProgress(0);
holdRafRef.current = requestAnimationFrame(tickHoldProgress);
};
const cancelHoldToFinish = () => {
if (isHoldCompletedRef.current) return;
resetHold();
};
useEffect(() => {
return () => {
if (holdRafRef.current !== null) {
cancelAnimationFrame(holdRafRef.current);
}
};
}, []);
const voyageRouteId = voyage?.routeId ?? "";
const route = voyageRouteId ? findRouteById(voyageRouteId) : null;
const routeName = route
? t(route.nameKey, undefined, voyage?.routeName ?? "")
: (voyage?.routeName ?? "");
const hasMissionText =
Boolean(voyage?.missionText?.trim()) &&
voyage?.missionText?.trim() !== "미입력";
const statusOptions = DEBRIEF_STATUS_OPTIONS.map((option) => ({
value: option.value as VoyageStatus,
label: t(option.labelKey),
desc: t(option.descKey),
}));
const crewRuntimeConfig = resolveCrewPresenceRuntimeConfig();
const hasSafeCrewRuntimeConfig =
isCrewPresenceRuntimeConfigSafe(crewRuntimeConfig);
const crewRenderCap = hasSafeCrewRuntimeConfig
? crewRuntimeConfig.renderCap
: CREW_PRESENCE_GUARDRAIL_FALLBACK_CAP;
const crewUpdateIntervalMs = hasSafeCrewRuntimeConfig
? crewRuntimeConfig.updateIntervalMs
: CREW_PRESENCE_GUARDRAIL_FALLBACK_INTERVAL_MS;
const isCrewPresencePanelVisible =
hasSafeCrewRuntimeConfig && crewRuntimeConfig.panelEnabled;
const isCrewNotificationPolicyEnabled =
hasSafeCrewRuntimeConfig && crewRuntimeConfig.notificationsEnabled;
const crewTotalCount = CREW_PRESENCE_MOCK_MEMBERS.length;
const visibleCrewMembers = CREW_PRESENCE_MOCK_MEMBERS.slice(0, crewRenderCap);
const overflowCrewCount = Math.max(0, crewTotalCount - crewRenderCap);
const filteredCrewNotifications: AllowedCrewPresenceNotification[] =
CREW_PRESENCE_MOCK_NOTIFICATIONS.filter(
(notification): notification is AllowedCrewPresenceNotification =>
isCrewNotificationTargetAllowed(notification.targetType),
);
const buildCommonCrewAnalyticsPayload = () => ({
decisionId: CREW_PRESENCE_DECISION_ID,
routeId: voyageRouteId,
totalCount: crewTotalCount,
visibleCount: visibleCrewMembers.length,
renderCap: crewRenderCap,
updateIntervalMs: crewUpdateIntervalMs,
});
const handleCrewPanelToggle = () => {
setIsCrewPanelCollapsed((prev) => {
const nextCollapsed = !prev;
trackCrewPresenceEvent("panel_collapsed", {
...buildCommonCrewAnalyticsPayload(),
collapsed: nextCollapsed,
});
return nextCollapsed;
});
};
const handlePresenceEventClick = (
targetType: CrewPresenceNotificationTarget,
) => {
trackCrewPresenceEvent("presence_event_click", {
...buildCommonCrewAnalyticsPayload(),
targetType,
});
};
useEffect(() => {
if (!isCrewPresencePanelVisible) {
return;
}
const trackPanelVisible = () => {
trackCrewPresenceEvent("panel_visible", {
...buildCommonCrewAnalyticsPayload(),
collapsed: isCrewPanelCollapsed,
});
};
trackPanelVisible();
const intervalId = window.setInterval(
trackPanelVisible,
crewUpdateIntervalMs,
);
return () => {
window.clearInterval(intervalId);
};
}, [
crewTotalCount,
isCrewPanelCollapsed,
isCrewPresencePanelVisible,
crewUpdateIntervalMs,
visibleCrewMembers.length,
voyageRouteId,
]);
if (!voyage) return null;
return (
<>
<div className="absolute top-8 z-10 text-center">
<span className="rounded-full border border-indigo-500/30 bg-indigo-950/50 px-4 py-1.5 text-xs font-medium uppercase tracking-widest text-indigo-300 shadow-[0_0_15px_rgba(99,102,241,0.3)] backdrop-blur">
{routeName} ·{" "}
{isPaused ? t("flight.badge.paused") : t("flight.badge.cruising")}
</span>
</div>
<div
className={`relative z-10 my-12 font-mono text-7xl font-light tracking-tighter tabular-nums drop-shadow-2xl transition-opacity duration-300 md:text-9xl ${isPaused ? "opacity-50" : "opacity-100"}`}
>
{formattedTime}
</div>
{hasMissionText && (
<div className="relative z-10 mb-24 w-full max-w-2xl px-4">
<section className="rounded-2xl border border-slate-600/60 bg-slate-950/55 p-4 text-left shadow-[0_16px_40px_rgba(2,6,23,0.35)] backdrop-blur md:p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400 md:text-xs">
{t("flight.missionLabel")}
</p>
<p className="mt-2 max-h-32 overflow-y-auto break-words whitespace-pre-wrap pr-1 text-base leading-relaxed text-slate-100 md:text-lg">
{voyage.missionText}
</p>
</section>
</div>
)}
{isCrewPresencePanelVisible && (
<div className="relative z-10 mb-8 w-full max-w-2xl px-4">
<section className="rounded-2xl border border-slate-600/60 bg-slate-950/55 p-4 text-left shadow-[0_16px_40px_rgba(2,6,23,0.35)] backdrop-blur md:p-6">
<div className="flex items-center justify-between gap-4">
<h2 className="text-sm font-semibold text-slate-100 md:text-base">
{t("flight.crewPresence.header", {
count: crewTotalCount,
})}
</h2>
<button
type="button"
onClick={handleCrewPanelToggle}
className="rounded-lg border border-slate-600/80 bg-slate-900/40 px-3 py-1.5 text-xs font-semibold text-slate-200 transition-colors hover:border-slate-400 hover:text-white"
>
{isCrewPanelCollapsed
? t("flight.crewPresence.expand")
: t("flight.crewPresence.collapse")}
</button>
</div>
{!isCrewPanelCollapsed && (
<div className="mt-4 space-y-3">
<p className="text-xs text-slate-400">
{t("flight.crewPresence.listLimit", {
limit: crewRenderCap,
})}
</p>
<p className="text-xs text-slate-400">
{t("flight.crewPresence.activityHint")}
</p>
<ul className="max-h-56 space-y-2 overflow-y-auto rounded-xl border border-slate-700/70 bg-slate-900/35 p-3">
{visibleCrewMembers.map((member) => (
<li
key={member.id}
className="space-y-1 rounded-lg border border-slate-700/70 bg-slate-900/60 px-3 py-2 text-sm text-slate-100"
>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">{member.name}</span>
<span className="rounded-md border border-slate-600/80 px-2 py-0.5 text-[11px] text-slate-200">
{t(CREW_STATUS_LABEL_KEYS[member.status])}
</span>
</div>
<div className="text-xs text-slate-300">
{t("flight.crewPresence.elapsedMinutes", {
minutes: member.elapsedMinutes,
})}
</div>
<div className="text-xs text-slate-300">
{member.goalVisibility === "public"
? member.goalText
: t(
CREW_GOAL_STATE_LABEL_KEYS[member.goalVisibility],
)}
</div>
</li>
))}
</ul>
{overflowCrewCount > 0 && (
<p className="text-xs font-semibold text-slate-300">
{t("flight.crewPresence.overflow", {
count: overflowCrewCount,
})}
</p>
)}
{isCrewNotificationPolicyEnabled &&
filteredCrewNotifications.length > 0 && (
<section className="rounded-xl border border-slate-700/70 bg-slate-900/35 p-3">
<p className="text-xs font-semibold text-slate-200">
{t("flight.crewPresence.notifications.policyOn")}
</p>
<p className="mt-1 text-xs text-slate-400">
{t("flight.crewPresence.notifications.filterHint")}
</p>
<ul className="mt-2 space-y-1.5">
{filteredCrewNotifications.map((notification) => (
<li key={notification.id}>
<button
type="button"
onClick={() =>
handlePresenceEventClick(
notification.targetType,
)
}
className="w-full rounded-md border border-slate-700/70 bg-slate-900/60 px-2.5 py-1.5 text-left text-xs text-slate-200"
>
<span className="font-semibold text-slate-100">
{t(
CREW_NOTIFICATION_TARGET_LABEL_KEYS[
notification.targetType
],
)}
</span>{" "}
<span className="text-slate-300">
{notification.message}
</span>
</button>
</li>
))}
</ul>
</section>
)}
</div>
)}
</section>
</div>
)}
<div className="absolute bottom-12 z-10 flex gap-6">
<button
onClick={handlePauseToggle}
className="rounded-full border border-slate-600 bg-slate-900/50 px-8 py-3 text-sm font-bold uppercase tracking-wide text-slate-300 backdrop-blur transition-all hover:border-slate-400 hover:bg-slate-800/80 hover:text-white"
>
{isPaused ? t("flight.resume") : t("flight.pause")}
</button>
<div className="relative">
<button
type="button"
onPointerDown={startHoldToFinish}
onPointerUp={cancelHoldToFinish}
onPointerLeave={cancelHoldToFinish}
onPointerCancel={cancelHoldToFinish}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
startHoldToFinish();
}
}}
onKeyUp={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
cancelHoldToFinish();
}
}}
onBlur={cancelHoldToFinish}
className="relative overflow-hidden rounded-full border border-slate-200 bg-slate-100 px-8 py-3 text-sm font-bold uppercase tracking-wide text-slate-900 shadow-lg shadow-white/10 transition-all hover:border-indigo-300 hover:text-slate-950"
>
<span
aria-hidden
className="absolute inset-0 origin-left bg-indigo-400/45"
style={{ transform: `scaleX(${holdProgress})` }}
/>
<span className="relative z-10">
{isCountdownCompleted
? t("flight.finish.debrief")
: t("flight.finish.end")}
</span>
</button>
</div>
</div>
<Dialog open={isDebriefOpen} onOpenChange={setIsDebriefOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto border-slate-700/80 bg-slate-950 text-slate-100">
<DialogHeader>
<DialogTitle className="text-white">
{t("flight.debrief.title")}
</DialogTitle>
<DialogDescription className="text-slate-400">
{t("flight.debrief.description")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleDebriefSubmit} className="space-y-6">
<section>
<label className="mb-3 block text-sm font-medium text-slate-300">
{t("debrief.status.label")}
</label>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
{statusOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setStatus(opt.value)}
className={`flex min-h-[116px] flex-col justify-between rounded-xl border px-4 py-3.5 text-left transition-all ${
status === opt.value
? "border-indigo-500 bg-indigo-900/40 ring-1 ring-indigo-500"
: "border-slate-800 bg-slate-900/50 hover:bg-slate-800"
}`}
>
<div className="text-sm leading-snug font-bold text-slate-200 break-keep">
{opt.label}
</div>
<div className="mt-2 text-[11px] leading-relaxed text-slate-500">
{opt.desc}
</div>
</button>
))}
</div>
</section>
<section>
<label className="mb-2 block text-sm font-medium text-slate-300">
{t("debrief.reflection.label")}
</label>
<input
type="text"
value={progress}
onChange={(event) => setProgress(event.target.value)}
placeholder={t("debrief.reflection.placeholder")}
className="w-full rounded-lg border border-slate-800 bg-slate-900/30 px-4 py-3 text-slate-200 outline-none transition-all focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
/>
</section>
<div className="grid grid-cols-2 gap-3 pt-1">
<button
type="button"
onClick={() => setIsDebriefOpen(false)}
className="rounded-xl border border-slate-700 bg-slate-900/60 px-4 py-3 font-semibold text-slate-300 transition-colors hover:border-slate-500 hover:text-white"
>
{t("boarding.cancel")}
</button>
<button
type="submit"
disabled={!status}
className="rounded-xl bg-indigo-600 px-4 py-3 font-semibold text-white transition-colors hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500"
>
{t("debrief.save")}
</button>
</div>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1 @@
export { LobbyBackgroundWidget } from './ui/LobbyBackgroundWidget';

View File

@@ -0,0 +1,9 @@
import { ConstellationScene } from '@/features/lobby-starfield';
export function LobbyBackgroundWidget() {
return (
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-black lg:left-72">
<ConstellationScene />
</div>
);
}

View File

@@ -0,0 +1 @@
export { LobbyRoutesPanel } from './ui/LobbyRoutesPanel';

View File

@@ -0,0 +1,217 @@
import { BookOpenText, Languages, Settings } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ChangeEvent, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { BoardingMissionForm, startVoyage } from "@/features/boarding";
import { useI18n } from "@/features/i18n/model/useI18n";
import { useLobbyRedirect } from "@/features/lobby-session/model/useLobbyRedirect";
import { LOCALE_LABELS, Locale, SUPPORTED_LOCALES } from "@/shared/config/i18n";
import { ROUTES } from "@/shared/config/routes";
function RouteCard({
route,
isCTA = false,
onLaunch,
}: {
route: (typeof ROUTES)[number];
isCTA?: boolean;
onLaunch: (route: (typeof ROUTES)[number]) => void;
}) {
const { t } = useI18n();
return (
<div
className={`group relative flex h-full flex-col rounded-2xl border border-slate-800 bg-slate-900/60 p-6 shadow-lg backdrop-blur-md transition-all duration-300 hover:border-indigo-500/50 hover:bg-slate-800/80 hover:shadow-indigo-900/20 ${isCTA ? "min-h-[200px] items-center justify-center text-center" : ""}`}
>
<div
className={`relative z-10 flex w-full ${isCTA ? "flex-col items-center gap-4" : "mb-4 items-start justify-between"}`}
>
<div className={isCTA ? "flex flex-col items-center" : ""}>
<h3
className={`font-bold text-indigo-100 transition-colors group-hover:text-white ${isCTA ? "text-3xl" : "text-xl"}`}
>
{t(route.nameKey, undefined, route.id)}
</h3>
<span className="mt-2 inline-block rounded-full border border-slate-800 bg-slate-950/50 px-2 py-0.5 text-xs font-medium text-slate-400">
{t(route.tagKey)}
</span>
</div>
{route.durationMinutes !== 0 && (
<span
className={`font-light text-slate-300 transition-colors group-hover:text-white ${isCTA ? "mt-2 text-4xl" : "text-3xl"}`}
>
{route.durationMinutes !== 0 && route.durationMinutes}
<span className="ml-1 text-sm text-slate-500">
{route.durationMinutes !== 0 && t("common.minuteShort")}
</span>
</span>
)}
</div>
{!isCTA && (
<p className="relative z-10 mb-8 w-full flex-1 text-left text-sm leading-relaxed text-slate-400">
{t(route.descriptionKey)}
</p>
)}
{isCTA && (
<p className="relative z-10 mt-2 mb-6 max-w-lg text-left text-base text-slate-400">
{t(route.descriptionKey)}
</p>
)}
<button
type="button"
onClick={() => onLaunch(route)}
className={`relative z-10 flex items-center justify-center rounded-xl bg-indigo-600 font-bold text-white shadow-lg shadow-indigo-900/30 transition-all hover:bg-indigo-500 active:scale-[0.98] ${isCTA ? "w-full max-w-md py-4 text-lg" : "w-full py-4"}`}
>
{isCTA ? t("lobby.cta.station") : t("lobby.cta.launch")}
</button>
</div>
);
}
export function LobbyRoutesPanel() {
const { locale, setLocale, t } = useI18n();
useLobbyRedirect();
const router = useRouter();
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
const [isBoardingOpen, setIsBoardingOpen] = useState(false);
const stationRoute = ROUTES[0];
const normalRoutes = ROUTES.slice(1);
const selectedRoute =
ROUTES.find((route) => route.id === selectedRouteId) ?? stationRoute;
const handleOpenBoarding = (route: (typeof ROUTES)[number]) => {
setSelectedRouteId(route.id);
setIsBoardingOpen(true);
};
const handleDocking = (mission: string) => {
const started = startVoyage({
route: selectedRoute,
mission,
routeName: t(selectedRoute.nameKey, undefined, selectedRoute.id),
});
if (!started) return;
setIsBoardingOpen(false);
router.push("/flight");
};
const handleLocaleChange = (event: ChangeEvent<HTMLSelectElement>) => {
setLocale(event.target.value as Locale);
};
return (
<div className="relative z-10 flex min-h-[calc(100vh-80px)] w-full flex-col p-6 md:p-12 lg:min-h-screen lg:flex-row lg:items-stretch lg:gap-0 lg:p-0">
<aside className="hidden w-72 shrink-0 border-r border-slate-700/80 bg-slate-800/65 px-7 py-8 shadow-[2px_0_24px_rgba(2,6,23,0.45)] backdrop-blur lg:flex lg:min-h-screen lg:flex-col lg:justify-between">
<div className="flex flex-col gap-10">
<h1 className="text-lg font-bold tracking-[0.14em] text-indigo-300">
FOCUSTELLA
</h1>
<nav className="flex flex-col gap-2">
<Link
href="/log"
className="flex items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-sm font-semibold text-slate-300 transition-colors hover:border-slate-700 hover:bg-slate-900/70 hover:text-slate-100"
>
<BookOpenText className="h-4 w-4 text-indigo-300" aria-hidden />
<span>{t("layout.nav.log")}</span>
</Link>
<Link
href="/settings"
className="flex items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-sm font-semibold text-slate-300 transition-colors hover:border-slate-700 hover:bg-slate-900/70 hover:text-slate-100"
>
<Settings className="h-4 w-4 text-indigo-300" aria-hidden />
<span>{t("layout.nav.settings")}</span>
</Link>
</nav>
</div>
<label className="flex items-center justify-between gap-3 rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2 text-xs text-slate-300">
<span className="inline-flex items-center gap-2">
<Languages className="h-4 w-4 text-indigo-300" aria-hidden />
{t("layout.nav.language")}
</span>
<select
value={locale}
onChange={handleLocaleChange}
className="rounded border border-slate-700 bg-slate-900/80 px-2 py-1 text-xs text-slate-200 outline-none transition-colors focus:border-indigo-400"
>
{SUPPORTED_LOCALES.map((item) => (
<option key={item} value={item}>
{LOCALE_LABELS[item]}
</option>
))}
</select>
</label>
</aside>
<div className="w-full flex-1 lg:min-h-screen lg:bg-slate-950/68 lg:px-10 lg:pt-10 lg:shadow-[inset_0_1px_0_rgba(148,163,184,0.06)]">
<div className="mx-auto mb-12 max-w-2xl space-y-4 rounded-3xl border border-slate-800/40 bg-slate-950/35 p-6 text-center backdrop-blur-sm">
<h1 className="text-3xl font-bold tracking-tight text-slate-100 md:text-5xl">
{t("lobby.title")}
</h1>
<p className="text-lg text-slate-300">{t("lobby.subtitle")}</p>
</div>
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 pb-8">
<div className="w-full">
<RouteCard
route={stationRoute}
isCTA={true}
onLaunch={handleOpenBoarding}
/>
</div>
<div className="grid w-full grid-cols-1 gap-6 md:grid-cols-2">
{normalRoutes.map((route) => (
<RouteCard
key={route.id}
route={route}
onLaunch={handleOpenBoarding}
/>
))}
</div>
</div>
</div>
<Dialog open={isBoardingOpen} onOpenChange={setIsBoardingOpen}>
<DialogContent
className="max-w-xl border-slate-800 bg-slate-950 text-slate-100"
showCloseButton={true}
>
<DialogHeader className="mb-2">
<h2 className="mb-1 text-sm font-semibold uppercase tracking-widest text-indigo-400">
{t("lobby.modal.boardingCheck")}
</h2>
<DialogTitle className="text-2xl font-bold text-white">
{t("lobby.modal.routeBoarding", {
routeName: t(selectedRoute.nameKey, undefined, selectedRoute.id),
})}
</DialogTitle>
<DialogDescription className="text-slate-400">
{t("lobby.modal.description")}
</DialogDescription>
</DialogHeader>
<BoardingMissionForm
onDock={handleDocking}
onCancel={() => setIsBoardingOpen(false)}
autoFocus={true}
compact={true}
/>
</DialogContent>
</Dialog>
</div>
);
}