fix(space): 배경 asset fallback 경로와 scene alias 해석 보강

맥락:
- /space 에서 forest 배경이 remote manifest asset 대신 기본 이미지로 조용히 fallback 될 수 있었다.
- scene key alias 와 manifest 실패 상태가 코드상 드러나지 않아 원인 추적이 어려웠다.

변경사항:
- media scene asset key 를 alias-aware 하게 정규화하고 asset source(fallback|remote) 메타를 추가했다.
- useMediaCatalog 가 remote manifest 실패와 fallback 사용 여부를 노출하도록 보강했다.
- SpaceWorkspaceWidget 에서 manifest 실패와 scene fallback 사용을 진단 로그/상태 메시지로 남기도록 정리했다.
- docs/work.md, docs/90_current_state.md, docs/session_brief.md 를 이번 작업 기준으로 갱신했다.

검증:
- npx tsc --noEmit

세션-상태: /space 배경 asset lookup 과 manifest fallback 진단을 보강했다.
세션-다음: forest/green-forest manifest 변형을 실제 브라우저에서 QA 한다.
세션-리스크: alias 목록 밖의 legacy scene id 는 추가 정규화가 필요할 수 있다.
This commit is contained in:
2026-03-11 13:35:44 +09:00
parent 9811134d8a
commit 4717bb3a1a
7 changed files with 163 additions and 123 deletions

View File

@@ -1,9 +1,13 @@
# 90. Current State # 90. Current State
Last Updated: 2026-03-05 Last Updated: 2026-03-11
## DONE ## 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 피드백 채널 단일화: - Focus 피드백 채널 단일화:
- HUD 내부 status line을 제거하고 상단 중앙 고정 토스트로 통합 - HUD 내부 status line을 제거하고 상단 중앙 고정 토스트로 통합
- Notes 저장/Undo, Goal 전환, 잠금 안내 피드백이 동일 위치에서 노출 - Notes 저장/Undo, Goal 전환, 잠금 안내 피드백이 동일 위치에서 노출
@@ -153,12 +157,14 @@ Last Updated: 2026-03-05
## NEXT ## NEXT
1. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 전환 감도/카피 마감 1. `/space`에서 `forest` / `green-forest` manifest 변형을 실제 브라우저 기준으로 QA
2. Notes(쓰기) / Inbox(읽기·정리) 복귀 흐름과 30초 숨고르기 톤 정리 2. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 전환 감도/카피 마감
3. Stage 가독성/모션/레이어 폴리시 최종 통일 3. Stage 가독성/모션/레이어 폴리시 최종 통일
## RISKS ## RISKS
- remote manifest 실패 시 원인 진단은 가능해졌지만, 사용자용 복구 액션 UI는 아직 없다
- alias 목록에 없는 legacy scene id가 추가되면 같은 fallback 문제가 재발할 수 있다
- `npm run build`는 네트워크 제한 시 Google Font fetch 실패 가능 - `npm run build`는 네트워크 제한 시 Google Font fetch 실패 가능
- localStorage 포맷 변경 시 이전 세션 저장값과의 호환성 이슈 가능 - localStorage 포맷 변경 시 이전 세션 저장값과의 호환성 이슈 가능
- Scene 추천값과 실제 사용자 선호가 어긋나면 자동 적용 체감 품질이 낮아질 수 있음 - Scene 추천값과 실제 사용자 선호가 어긋나면 자동 적용 체감 품질이 낮아질 수 있음
@@ -182,6 +188,14 @@ Last Updated: 2026-03-05
## CHANGED FILES ## 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 반영) - (최근 workflow 반영)
- `src/widgets/space-workspace/ui/FocusTopToast.tsx` - `src/widgets/space-workspace/ui/FocusTopToast.tsx`
- `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx` - `src/widgets/space-workspace/ui/SpaceWorkspaceWidget.tsx`

View File

@@ -1,6 +1,6 @@
# Session Brief # 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 플로우(완료 → 다음 한 조각) 마감 품질 점검 1. `/space` forest 배경이 `forest` / `green-forest` manifest key 모두에서 동일하게 붙는지 브라우저 QA
2. Notes(쓰기) / Inbox(읽기·정리) 복귀 동선과 30초 숨고르기 카피 정리 2. Goal Complete Sheet 플로우(완료 → 다음 한 조각) 마감 품질 점검
3. Stage 가독성/모션/레이어 폴리시 최종 정리 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곳으로 통합했다. - Focus 피드백 채널을 상단 중앙 1곳으로 통합했다.
- HUD 내부 status line 제거 - HUD 내부 status line 제거
- Notes/Goal/잠금 피드백이 동일 위치 토스트로 표시 - 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 실패 가능 - 네트워크 제한 환경에서는 `npm run build` 시 Google Fonts fetch 실패 가능
- localStorage 저장 포맷 변경 시 이전 세션 데이터와의 호환성 이슈가 생길 수 있음 - localStorage 저장 포맷 변경 시 이전 세션 데이터와의 호환성 이슈가 생길 수 있음
- Scene 추천값이 사용자 선호와 어긋나면 자동 추천 체감 품질이 낮을 수 있음 - Scene 추천값이 사용자 선호와 어긋나면 자동 추천 체감 품질이 낮을 수 있음

View File

@@ -17,111 +17,22 @@
## 작업 1 ## 작업 1
- 제목: 코어 루프 완성 — Goal Complete Sheet(다음 한 조각 입력) 마감 - 제목: Space 배경 asset 해석 안정화 - forest R2 배경 fallback 제거
- 목적: - 목적:
- 이 앱의 재방문/체감은 “완료 → 다음 목표”가 자연스럽게 이어질 때 생긴다. - `/space`에서 `forest` 배경이 R2 asset 대신 기본 이미지로 조용히 fallback 되는 원인을 제거한다.
- Focus 화면에서 목표 완료가 폼 UI처럼 보이지 않도록 하고, 완료 후 다음 한 조각 입력 플로우를 프리미엄스럽게 만든다. - scene asset miss가 나도 원인을 코드상 추적 가능하게 만들어 재발을 막는다.
- 변경 범위: - 변경 범위:
- Focus HUD의 목표는 “1줄 앵커”로 유지(상시 큰 카드 금지) - media manifest의 scene asset key를 scene alias까지 고려해 해석하도록 보강
- 완료 트리거(1개만 선택해 고정): - `/space` 배경이 scene asset miss 또는 manifest load 실패 시 조용히 기본 이미지로만 끝나지 않도록 진단 정보 추가
- Goal 1줄 앵커 롱프레스(1초) 또는 작은 ghost ‘완료’(체크박스 금지) - 기존 sound playback 동작과 UI 흐름은 유지
- 완료 시 Goal Complete Sheet 표시(하단 시트)
- 타이틀 + 입력 1개 + 추천 칩 4개 + CTA 2개(바로 다음 조각 시작 / 잠깐 쉬기)
- Primary 클릭 시 다음 목표로 교체(더미) + 시트 닫기
- Secondary는 Break(더미) 또는 토스트 + 시트 닫기
- 전역 블러/딤 금지, 모션 200~300ms 저자극
- 제외 범위: - 제외 범위:
- 서버/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로 다음 한 조각 루프 완성 - fix(space): 배경 asset fallback 경로와 scene alias 해석 보강
---
## 작업 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(더미) 연결

View File

@@ -1,15 +1,18 @@
import type { CSSProperties } from 'react'; 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 { DEFAULT_MEDIA_MANIFEST } from './mockMediaManifest';
import type { import type {
MediaManifest, MediaManifest,
SceneAssetManifestItem, SceneAssetManifestItem,
SceneAssetMap, SceneAssetMap,
SoundAssetManifestItem,
SoundAssetMap, SoundAssetMap,
} from './types'; } from './types';
const DEFAULT_STAGE_GRADIENT = 'linear-gradient(160deg, #1e293b 0%, #0f172a 100%)'; 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 isAbsoluteUrl = (value: string) => /^(?:[a-z]+:)?\/\//i.test(value);
const resolveAssetUrl = (value: string | null | undefined, baseUrl?: string | null) => { 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 mergeSceneAssets = (manifest: MediaManifest) => {
const bySceneId = new Map( 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) { for (const asset of manifest.scenes) {
bySceneId.set(asset.sceneId, { const normalizedSceneId = normalizeSceneAssetId(asset.sceneId);
...bySceneId.get(asset.sceneId),
bySceneId.set(normalizedSceneId, {
...bySceneId.get(normalizedSceneId),
...asset, ...asset,
sceneId: normalizedSceneId,
source: 'remote',
}); });
} }
@@ -55,13 +73,20 @@ const mergeSceneAssets = (manifest: MediaManifest) => {
const mergeSoundAssets = (manifest: MediaManifest) => { const mergeSoundAssets = (manifest: MediaManifest) => {
const byPresetId = new Map( 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) { for (const asset of manifest.sounds) {
byPresetId.set(asset.presetId, { byPresetId.set(asset.presetId, {
...byPresetId.get(asset.presetId), ...byPresetId.get(asset.presetId),
...asset, ...asset,
source: 'remote',
}); });
} }
@@ -91,7 +116,7 @@ export const normalizeMediaManifest = (manifest: Partial<MediaManifest> | null |
export const buildSceneAssetMap = (manifest: MediaManifest): SceneAssetMap => { export const buildSceneAssetMap = (manifest: MediaManifest): SceneAssetMap => {
return manifest.scenes.reduce<SceneAssetMap>((accumulator, asset) => { return manifest.scenes.reduce<SceneAssetMap>((accumulator, asset) => {
accumulator[asset.sceneId] = asset; accumulator[normalizeSceneAssetId(asset.sceneId)] = asset;
return accumulator; return accumulator;
}, {}); }, {});
}; };

View File

@@ -6,6 +6,7 @@ export interface SceneAssetManifestItem {
hdStageUrl?: string | null; hdStageUrl?: string | null;
placeholderGradient?: string | null; placeholderGradient?: string | null;
blurDataUrl?: string | null; blurDataUrl?: string | null;
source?: 'fallback' | 'remote';
} }
export interface SoundAssetManifestItem { export interface SoundAssetManifestItem {
@@ -16,6 +17,7 @@ export interface SoundAssetManifestItem {
mimeType?: 'audio/mp4' | 'audio/mpeg' | 'audio/webm' | null; mimeType?: 'audio/mp4' | 'audio/mpeg' | 'audio/webm' | null;
durationSec?: number | null; durationSec?: number | null;
defaultVolume?: number | null; defaultVolume?: number | null;
source?: 'fallback' | 'remote';
} }
export interface MediaManifest { export interface MediaManifest {

View File

@@ -10,12 +10,22 @@ import {
} from './resolveMediaAsset'; } from './resolveMediaAsset';
import type { MediaManifest } from './types'; import type { MediaManifest } from './types';
let manifestCache = normalizeMediaManifest(DEFAULT_MEDIA_MANIFEST); type MediaCatalogLoadResult = {
let manifestRequest: Promise<MediaManifest> | null = null; manifest: MediaManifest;
error: string | null;
usedFallbackManifest: boolean;
};
const readMediaManifest = async (signal?: AbortSignal) => { let manifestCache = normalizeMediaManifest(DEFAULT_MEDIA_MANIFEST);
let manifestRequest: Promise<MediaCatalogLoadResult> | null = null;
const readMediaManifest = async (signal?: AbortSignal): Promise<MediaCatalogLoadResult> => {
if (!MEDIA_MANIFEST_URL) { if (!MEDIA_MANIFEST_URL) {
return manifestCache; return {
manifest: manifestCache,
error: null,
usedFallbackManifest: false,
};
} }
if (!manifestRequest) { if (!manifestRequest) {
@@ -23,9 +33,21 @@ const readMediaManifest = async (signal?: AbortSignal) => {
.getManifest(signal) .getManifest(signal)
.then((manifest) => { .then((manifest) => {
manifestCache = 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(() => { .finally(() => {
manifestRequest = null; manifestRequest = null;
}); });
@@ -36,12 +58,18 @@ const readMediaManifest = async (signal?: AbortSignal) => {
export const useMediaCatalog = () => { export const useMediaCatalog = () => {
const [manifest, setManifest] = useState<MediaManifest>(manifestCache); const [manifest, setManifest] = useState<MediaManifest>(manifestCache);
const [error, setError] = useState<string | null>(null);
const [usedFallbackManifest, setUsedFallbackManifest] = useState(false);
const [hasResolvedManifest, setHasResolvedManifest] = useState(!MEDIA_MANIFEST_URL);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
void readMediaManifest(controller.signal).then((nextManifest) => { void readMediaManifest(controller.signal).then((result) => {
setManifest(nextManifest); setManifest(result.manifest);
setError(result.error);
setUsedFallbackManifest(result.usedFallbackManifest);
setHasResolvedManifest(true);
}); });
return () => { return () => {
@@ -56,5 +84,8 @@ export const useMediaCatalog = () => {
manifest, manifest,
sceneAssetMap, sceneAssetMap,
soundAssetMap, soundAssetMap,
error,
usedFallbackManifest,
hasResolvedManifest,
}; };
}; };

View File

@@ -208,8 +208,16 @@ export const SpaceWorkspaceWidget = () => {
}); });
const queuedFocusStatusMessageRef = useRef<string | null>(null); const queuedFocusStatusMessageRef = useRef<string | null>(null);
const lastSoundPlaybackErrorRef = useRef<string | null>(null); const lastSoundPlaybackErrorRef = useRef<string | null>(null);
const lastMediaManifestErrorRef = useRef<string | null>(null);
const lastFallbackSceneDiagnosticRef = useRef<string | null>(null);
const didHydrateServerPreferencesRef = useRef(false); const didHydrateServerPreferencesRef = useRef(false);
const { sceneAssetMap, soundAssetMap } = useMediaCatalog(); const {
sceneAssetMap,
soundAssetMap,
error: mediaCatalogError,
usedFallbackManifest,
hasResolvedManifest,
} = useMediaCatalog();
const { const {
selectedPresetId, selectedPresetId,
@@ -778,6 +786,49 @@ export const SpaceWorkspaceWidget = () => {
}); });
}, [pushStatusLine, soundPlaybackError]); }, [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 ( return (
<div className="relative h-dvh overflow-hidden text-white"> <div className="relative h-dvh overflow-hidden text-white">
<div <div