diff --git a/docs/90_current_state.md b/docs/90_current_state.md index 2593ce0..10ea9cd 100644 --- a/docs/90_current_state.md +++ b/docs/90_current_state.md @@ -1,9 +1,13 @@ # 90. Current State -Last Updated: 2026-03-05 +Last Updated: 2026-03-11 ## DONE +- `/space` 배경 asset 해석 안정화: + - media manifest scene key를 scene alias까지 정규화해 `green-forest`와 `forest`를 동일 asset으로 해석 + - scene/sound asset에 `source(fallback|remote)` 메타를 추가해 실제 fallback 사용 여부를 구분 가능하게 정리 + - remote manifest load 실패 시 error 상태를 노출하고, `/space`에서 manifest 실패/scene fallback 사용을 진단 로그로 남기도록 보강 - Focus 피드백 채널 단일화: - HUD 내부 status line을 제거하고 상단 중앙 고정 토스트로 통합 - Notes 저장/Undo, Goal 전환, 잠금 안내 피드백이 동일 위치에서 노출 @@ -153,12 +157,14 @@ Last Updated: 2026-03-05 ## NEXT -1. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 전환 감도/카피 마감 -2. Notes(쓰기) / Inbox(읽기·정리) 복귀 흐름과 30초 숨고르기 톤 정리 +1. `/space`에서 `forest` / `green-forest` manifest 변형을 실제 브라우저 기준으로 QA +2. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 전환 감도/카피 마감 3. Stage 가독성/모션/레이어 폴리시 최종 통일 ## RISKS +- remote manifest 실패 시 원인 진단은 가능해졌지만, 사용자용 복구 액션 UI는 아직 없다 +- alias 목록에 없는 legacy scene id가 추가되면 같은 fallback 문제가 재발할 수 있다 - `npm run build`는 네트워크 제한 시 Google Font fetch 실패 가능 - localStorage 포맷 변경 시 이전 세션 저장값과의 호환성 이슈 가능 - Scene 추천값과 실제 사용자 선호가 어긋나면 자동 적용 체감 품질이 낮아질 수 있음 @@ -182,6 +188,14 @@ Last Updated: 2026-03-05 ## CHANGED FILES +- (이번 세션) + - `src/entities/media/model/types.ts` + - `src/entities/media/model/resolveMediaAsset.ts` + - `src/entities/media/model/useMediaCatalog.ts` + - `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx` + - `docs/work.md` + - `docs/90_current_state.md` + - `docs/session_brief.md` - (최근 workflow 반영) - `src/widgets/space-workspace/ui/FocusTopToast.tsx` - `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx` diff --git a/docs/session_brief.md b/docs/session_brief.md index ac38dde..924a7c7 100644 --- a/docs/session_brief.md +++ b/docs/session_brief.md @@ -1,6 +1,6 @@ # Session Brief -Last Updated: 2026-03-05 +Last Updated: 2026-03-11 세션 시작 시 항상 읽는 초소형 스냅샷 문서. @@ -14,12 +14,16 @@ Last Updated: 2026-03-05 ## 현재 우선순위 -1. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 마감 품질 점검 -2. Notes(쓰기) / Inbox(읽기·정리) 복귀 동선과 30초 숨고르기 카피 정리 +1. `/space` forest 배경이 `forest` / `green-forest` manifest key 모두에서 동일하게 붙는지 브라우저 QA +2. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 마감 품질 점검 3. Stage 가독성/모션/레이어 폴리시 최종 정리 ## 최근 세션 상태 +- `/space` 배경 asset 해석을 보강했다. + - media manifest scene key를 alias-aware 하게 정규화해 `green-forest`와 `forest`를 같은 scene asset으로 읽는다. + - scene/sound asset에 `source(fallback|remote)` 메타를 추가해 remote asset 사용 여부를 코드에서 바로 식별할 수 있다. + - remote manifest load 실패와 scene fallback 사용 시 `/space`에서 진단 로그를 남기도록 보강했다. - Focus 피드백 채널을 상단 중앙 1곳으로 통합했다. - HUD 내부 status line 제거 - Notes/Goal/잠금 피드백이 동일 위치 토스트로 표시 @@ -114,6 +118,8 @@ Last Updated: 2026-03-05 ## 리스크 +- remote manifest 실패 시 원인 진단은 가능하지만, 사용자용 복구 CTA는 아직 없다. +- alias 목록에 없는 legacy scene id가 다시 들어오면 scene fallback 문제가 재발할 수 있다. - 네트워크 제한 환경에서는 `npm run build` 시 Google Fonts fetch 실패 가능 - localStorage 저장 포맷 변경 시 이전 세션 데이터와의 호환성 이슈가 생길 수 있음 - Scene 추천값이 사용자 선호와 어긋나면 자동 추천 체감 품질이 낮을 수 있음 diff --git a/docs/work.md b/docs/work.md index a35f2ad..e8feb19 100644 --- a/docs/work.md +++ b/docs/work.md @@ -17,111 +17,22 @@ ## 작업 1 -- 제목: 코어 루프 완성 — Goal Complete Sheet(다음 한 조각 입력) 마감 +- 제목: Space 배경 asset 해석 안정화 - forest R2 배경 fallback 제거 - 목적: - - 이 앱의 재방문/체감은 “완료 → 다음 목표”가 자연스럽게 이어질 때 생긴다. - - Focus 화면에서 목표 완료가 폼 UI처럼 보이지 않도록 하고, 완료 후 다음 한 조각 입력 플로우를 프리미엄스럽게 만든다. + - `/space`에서 `forest` 배경이 R2 asset 대신 기본 이미지로 조용히 fallback 되는 원인을 제거한다. + - scene asset miss가 나도 원인을 코드상 추적 가능하게 만들어 재발을 막는다. - 변경 범위: - - Focus HUD의 목표는 “1줄 앵커”로 유지(상시 큰 카드 금지) - - 완료 트리거(1개만 선택해 고정): - - Goal 1줄 앵커 롱프레스(1초) 또는 작은 ghost ‘완료’(체크박스 금지) - - 완료 시 Goal Complete Sheet 표시(하단 시트) - - 타이틀 + 입력 1개 + 추천 칩 4개 + CTA 2개(바로 다음 조각 시작 / 잠깐 쉬기) - - Primary 클릭 시 다음 목표로 교체(더미) + 시트 닫기 - - Secondary는 Break(더미) 또는 토스트 + 시트 닫기 - - 전역 블러/딤 금지, 모션 200~300ms 저자극 + - media manifest의 scene asset key를 scene alias까지 고려해 해석하도록 보강 + - `/space` 배경이 scene asset miss 또는 manifest load 실패 시 조용히 기본 이미지로만 끝나지 않도록 진단 정보 추가 + - 기존 sound playback 동작과 UI 흐름은 유지 - 제외 범위: - - 서버/DB/통계/실제 타이머 로직 구현 금지 + - 백엔드 manifest 스키마 변경 금지 + - R2 업로드 파이프라인 수정 금지 + - focus timer / session / audio 동작 변경 금지 - 완료 조건: - - 완료 → 다음 목표 입력 → 바로 시작이 2~3스텝 내로 끝난다. + - `forest`와 `green-forest` 어느 key로 scene asset이 내려와도 `/space`에서 같은 asset을 찾는다. + - remote manifest를 못 읽는 경우 원인을 코드상 드러낼 수 있다. - 검증: - - npx tsc --noEmit + - `npx tsc --noEmit` - 커밋 힌트: - - feat(goal): Goal Complete Sheet로 다음 한 조각 루프 완성 - ---- - -## 작업 2 - -- 제목: 세션 이어가기(Resume) — 새로고침/재진입 시 “지난 한 조각 이어서” -- 목적: - - 출시 전이라도 “다시 들어왔을 때 바로 이어서 시작”이 되면 사용성이 급격히 좋아지고 재방문을 만든다. -- 변경 범위: - - 로컬 저장(더미)으로 마지막 상태를 복원: - - 마지막 목표, Scene, Timer, Sound, override flags - - /space 진입 시 “지난 한 조각 이어서”를 조용한 CTA로 제공(Setup가 아니라 Focus 진입 직전에 1회) - - 사용자가 거절하면 새 세션(목표 입력)로 - - 카피는 저자극/확정 표현 금지 -- 제외 범위: - - 로그인/서버 동기화 금지 -- 완료 조건: - - 새로고침 후에도 마지막 세션이 이어지는 것처럼 보이고, 이어서 시작이 가능하다. -- 검증: - - npx tsc --noEmit -- 커밋 힌트: - - feat(resume): 지난 세션 이어서(더미) 플로우 추가 - ---- - -## 작업 3 - -- 제목: Recover 시그니처 — Notes(쓰기 전용) → Inbox(읽기/정리) + 30초 숨고르기 정리 -- 목적: - - ADHD 타겟의 차별점은 “산만해져도 다시 돌아오는 비용”을 줄이는 것이다. - - 쓰기와 읽기/정리를 분리해 몰입을 깨지 않게 한다. -- 변경 범위: - - Notes 팝오버는 쓰기 전용(리스트/정리 버튼 제거) - - Inbox는 도크 시트에서 읽기/정리(완료/삭제 + Undo 더미) - - 30초 숨고르기(더미) 흐름 정리: - - 버튼 카피/위치/동작을 “다시 돌아오기” 느낌으로 - - 과한 UI 추가 금지 -- 제외 범위: - - 실제 타이머/오디오 로직 구현 금지 -- 완료 조건: - - Focus 중에는 쓰기만, 정리는 Inbox에서만 가능하며 복귀 흐름이 자연스럽다. -- 검증: - - npx tsc --noEmit -- 커밋 힌트: - - feat(recover): Notes→Inbox 복귀 흐름 및 30초 숨고르기 정리 - ---- - -## 작업 4 - -- 제목: Stage 폴리시 규칙 고정 + 마감(가독성/모션/레이어) -- 목적: - - Portal/LifeAt 느낌은 “미세한 마감”에서 결정된다. - - 앞선 코어 동선이 확정된 후, 가독성과 모션/레이어를 일관되게 다듬는다. -- 변경 범위: - - 밝은/어두운 배경 모두에서 HUD/앵커 가독성 안정(전역 blur 금지, 로컬 스크림 최소) - - 모션 200~300ms 저자극 통일 - - 아이콘/버튼 간격/재질 통일(글래스 톤) -- 제외 범위: - - 기능 추가 금지(스타일/레이어만) -- 완료 조건: - - 배경이 달라도 핵심 정보가 항상 읽히고, 전체가 프리미엄스럽게 정돈된다. -- 검증: - - npx tsc --noEmit -- 커밋 힌트: - - style(stage): 가독성/모션/레이어 폴리시 - ---- - -## 작업 5 - -- 제목: Pro/Paywall 최소 연결(의도 기반) — Packs/Profiles 중심 -- 목적: - - 기본 기능 잠금 없이, 확장/품질/개인화로 유료 이유를 만든다. - - Focus를 방해하지 않고 클릭 의도 기반으로만 paywall을 연다. -- 변경 범위: - - Time 같은 기본 기능 LOCK 제거 유지 - - Pro는 Scene Packs / Sound Packs / Profile 저장으로 재배치 - - Paywall Sheet(더미) 구현: 잠긴 항목 클릭 시에만 노출 -- 제외 범위: - - 실제 결제 연동 금지 -- 완료 조건: - - Pro가 “확장/팩/개인화”로 이해되고, Focus 흐름을 방해하지 않는다. -- 검증: - - npx tsc --noEmit -- 커밋 힌트: - - feat(paywall): 의도 기반 Pro 진입/Paywall(더미) 연결 + - fix(space): 배경 asset fallback 경로와 scene alias 해석 보강 diff --git a/src/entities/media/model/resolveMediaAsset.ts b/src/entities/media/model/resolveMediaAsset.ts index b111d6d..96c41b6 100644 --- a/src/entities/media/model/resolveMediaAsset.ts +++ b/src/entities/media/model/resolveMediaAsset.ts @@ -1,15 +1,18 @@ import type { CSSProperties } from 'react'; -import type { SceneTheme } from '@/entities/scene'; +import { normalizeSceneId, type SceneTheme } from '@/entities/scene'; import { DEFAULT_MEDIA_MANIFEST } from './mockMediaManifest'; import type { MediaManifest, SceneAssetManifestItem, SceneAssetMap, + SoundAssetManifestItem, SoundAssetMap, } from './types'; const DEFAULT_STAGE_GRADIENT = 'linear-gradient(160deg, #1e293b 0%, #0f172a 100%)'; +const normalizeSceneAssetId = (sceneId: string) => normalizeSceneId(sceneId) ?? sceneId; + const isAbsoluteUrl = (value: string) => /^(?:[a-z]+:)?\/\//i.test(value); const resolveAssetUrl = (value: string | null | undefined, baseUrl?: string | null) => { @@ -34,13 +37,28 @@ const resolveAssetUrl = (value: string | null | undefined, baseUrl?: string | nu const mergeSceneAssets = (manifest: MediaManifest) => { const bySceneId = new Map( - DEFAULT_MEDIA_MANIFEST.scenes.map((asset) => [asset.sceneId, asset]), + DEFAULT_MEDIA_MANIFEST.scenes.map((asset) => { + const normalizedSceneId = normalizeSceneAssetId(asset.sceneId); + + return [ + normalizedSceneId, + { + ...asset, + sceneId: normalizedSceneId, + source: 'fallback', + } as SceneAssetManifestItem, + ] as const; + }), ); for (const asset of manifest.scenes) { - bySceneId.set(asset.sceneId, { - ...bySceneId.get(asset.sceneId), + const normalizedSceneId = normalizeSceneAssetId(asset.sceneId); + + bySceneId.set(normalizedSceneId, { + ...bySceneId.get(normalizedSceneId), ...asset, + sceneId: normalizedSceneId, + source: 'remote', }); } @@ -55,13 +73,20 @@ const mergeSceneAssets = (manifest: MediaManifest) => { const mergeSoundAssets = (manifest: MediaManifest) => { const byPresetId = new Map( - DEFAULT_MEDIA_MANIFEST.sounds.map((asset) => [asset.presetId, asset]), + DEFAULT_MEDIA_MANIFEST.sounds.map((asset) => [ + asset.presetId, + { + ...asset, + source: 'fallback', + } as SoundAssetManifestItem, + ]), ); for (const asset of manifest.sounds) { byPresetId.set(asset.presetId, { ...byPresetId.get(asset.presetId), ...asset, + source: 'remote', }); } @@ -91,7 +116,7 @@ export const normalizeMediaManifest = (manifest: Partial | null | export const buildSceneAssetMap = (manifest: MediaManifest): SceneAssetMap => { return manifest.scenes.reduce((accumulator, asset) => { - accumulator[asset.sceneId] = asset; + accumulator[normalizeSceneAssetId(asset.sceneId)] = asset; return accumulator; }, {}); }; diff --git a/src/entities/media/model/types.ts b/src/entities/media/model/types.ts index 6926ee9..e6f2c9c 100644 --- a/src/entities/media/model/types.ts +++ b/src/entities/media/model/types.ts @@ -6,6 +6,7 @@ export interface SceneAssetManifestItem { hdStageUrl?: string | null; placeholderGradient?: string | null; blurDataUrl?: string | null; + source?: 'fallback' | 'remote'; } export interface SoundAssetManifestItem { @@ -16,6 +17,7 @@ export interface SoundAssetManifestItem { mimeType?: 'audio/mp4' | 'audio/mpeg' | 'audio/webm' | null; durationSec?: number | null; defaultVolume?: number | null; + source?: 'fallback' | 'remote'; } export interface MediaManifest { diff --git a/src/entities/media/model/useMediaCatalog.ts b/src/entities/media/model/useMediaCatalog.ts index 3180a28..290f83b 100644 --- a/src/entities/media/model/useMediaCatalog.ts +++ b/src/entities/media/model/useMediaCatalog.ts @@ -10,12 +10,22 @@ import { } from './resolveMediaAsset'; import type { MediaManifest } from './types'; -let manifestCache = normalizeMediaManifest(DEFAULT_MEDIA_MANIFEST); -let manifestRequest: Promise | null = null; +type MediaCatalogLoadResult = { + manifest: MediaManifest; + error: string | null; + usedFallbackManifest: boolean; +}; -const readMediaManifest = async (signal?: AbortSignal) => { +let manifestCache = normalizeMediaManifest(DEFAULT_MEDIA_MANIFEST); +let manifestRequest: Promise | null = null; + +const readMediaManifest = async (signal?: AbortSignal): Promise => { if (!MEDIA_MANIFEST_URL) { - return manifestCache; + return { + manifest: manifestCache, + error: null, + usedFallbackManifest: false, + }; } if (!manifestRequest) { @@ -23,9 +33,21 @@ const readMediaManifest = async (signal?: AbortSignal) => { .getManifest(signal) .then((manifest) => { manifestCache = manifest; - return manifest; + return { + manifest, + error: null, + usedFallbackManifest: false, + }; + }) + .catch((error) => { + const nextError = error instanceof Error ? error.message : null; + + return { + manifest: manifestCache, + error: nextError, + usedFallbackManifest: true, + }; }) - .catch(() => manifestCache) .finally(() => { manifestRequest = null; }); @@ -36,12 +58,18 @@ const readMediaManifest = async (signal?: AbortSignal) => { export const useMediaCatalog = () => { const [manifest, setManifest] = useState(manifestCache); + const [error, setError] = useState(null); + const [usedFallbackManifest, setUsedFallbackManifest] = useState(false); + const [hasResolvedManifest, setHasResolvedManifest] = useState(!MEDIA_MANIFEST_URL); useEffect(() => { const controller = new AbortController(); - void readMediaManifest(controller.signal).then((nextManifest) => { - setManifest(nextManifest); + void readMediaManifest(controller.signal).then((result) => { + setManifest(result.manifest); + setError(result.error); + setUsedFallbackManifest(result.usedFallbackManifest); + setHasResolvedManifest(true); }); return () => { @@ -56,5 +84,8 @@ export const useMediaCatalog = () => { manifest, sceneAssetMap, soundAssetMap, + error, + usedFallbackManifest, + hasResolvedManifest, }; }; diff --git a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx index 4cf687c..d5dc939 100644 --- a/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx +++ b/src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx @@ -208,8 +208,16 @@ export const SpaceWorkspaceWidget = () => { }); const queuedFocusStatusMessageRef = useRef(null); const lastSoundPlaybackErrorRef = useRef(null); + const lastMediaManifestErrorRef = useRef(null); + const lastFallbackSceneDiagnosticRef = useRef(null); const didHydrateServerPreferencesRef = useRef(false); - const { sceneAssetMap, soundAssetMap } = useMediaCatalog(); + const { + sceneAssetMap, + soundAssetMap, + error: mediaCatalogError, + usedFallbackManifest, + hasResolvedManifest, + } = useMediaCatalog(); const { selectedPresetId, @@ -778,6 +786,49 @@ export const SpaceWorkspaceWidget = () => { }); }, [pushStatusLine, soundPlaybackError]); + useEffect(() => { + if (!mediaCatalogError) { + lastMediaManifestErrorRef.current = null; + return; + } + + if (mediaCatalogError === lastMediaManifestErrorRef.current) { + return; + } + + lastMediaManifestErrorRef.current = mediaCatalogError; + console.error('[media] Failed to load remote media manifest.', { + error: mediaCatalogError, + sceneId: selectedScene.id, + }); + pushStatusLine({ + message: mediaCatalogError, + }); + }, [mediaCatalogError, pushStatusLine, selectedScene.id]); + + useEffect(() => { + if (!hasResolvedManifest || usedFallbackManifest) { + return; + } + + const isUsingFallbackSceneAsset = !selectedSceneAsset || selectedSceneAsset.source === 'fallback'; + + if (!isUsingFallbackSceneAsset) { + lastFallbackSceneDiagnosticRef.current = null; + return; + } + + if (lastFallbackSceneDiagnosticRef.current === selectedScene.id) { + return; + } + + lastFallbackSceneDiagnosticRef.current = selectedScene.id; + console.warn('[space] Selected scene is using fallback asset data.', { + sceneId: selectedScene.id, + asset: selectedSceneAsset ?? null, + }); + }, [hasResolvedManifest, selectedScene.id, selectedSceneAsset, usedFallbackManifest]); + return (