style(space): 상단 토스트 크기·패딩·타이포 조정
This commit is contained in:
129
docs/work.md
129
docs/work.md
@@ -1,2 +1,127 @@
|
|||||||
프로필 왼쪽의 등급 칩의 글자가 너무 작다.
|
# Work Order
|
||||||
실제로 돈을 낸 사람들이 대우를 받을 수 있다는 느낌이 들도록 좀더 세련된 느낌을 줘야한다.
|
|
||||||
|
이 파일은 이번 세션에서 처리할 작업을 적는 실행 입력서다.
|
||||||
|
|
||||||
|
## 작성 규칙
|
||||||
|
|
||||||
|
- 작업은 가능한 한 "주제별"로 분리해서 작성한다.
|
||||||
|
- 한 주제는 가능하면 한 커밋으로 끝낼 수 있게 범위를 좁힌다.
|
||||||
|
- "금지사항/제외 범위"를 명시해서 불필요한 변경을 막는다.
|
||||||
|
|
||||||
|
## 우선순위
|
||||||
|
|
||||||
|
- 위에서 아래 순서대로 높은 우선순위로 간주한다.
|
||||||
|
- `작업 1`을 먼저 처리하고, 완료 시 다음 작업으로 넘어간다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 작업 1
|
||||||
|
|
||||||
|
- 제목: 코어 루프 완성 — Goal Complete Sheet(다음 한 조각 입력) 마감
|
||||||
|
- 목적:
|
||||||
|
- 이 앱의 재방문/체감은 “완료 → 다음 목표”가 자연스럽게 이어질 때 생긴다.
|
||||||
|
- Focus 화면에서 목표 완료가 폼 UI처럼 보이지 않도록 하고, 완료 후 다음 한 조각 입력 플로우를 프리미엄스럽게 만든다.
|
||||||
|
- 변경 범위:
|
||||||
|
- Focus HUD의 목표는 “1줄 앵커”로 유지(상시 큰 카드 금지)
|
||||||
|
- 완료 트리거(1개만 선택해 고정):
|
||||||
|
- Goal 1줄 앵커 롱프레스(1초) 또는 작은 ghost ‘완료’(체크박스 금지)
|
||||||
|
- 완료 시 Goal Complete Sheet 표시(하단 시트)
|
||||||
|
- 타이틀 + 입력 1개 + 추천 칩 4개 + CTA 2개(바로 다음 조각 시작 / 잠깐 쉬기)
|
||||||
|
- Primary 클릭 시 다음 목표로 교체(더미) + 시트 닫기
|
||||||
|
- Secondary는 Break(더미) 또는 토스트 + 시트 닫기
|
||||||
|
- 전역 블러/딤 금지, 모션 200~300ms 저자극
|
||||||
|
- 제외 범위:
|
||||||
|
- 서버/DB/통계/실제 타이머 로직 구현 금지
|
||||||
|
- 완료 조건:
|
||||||
|
- 완료 → 다음 목표 입력 → 바로 시작이 2~3스텝 내로 끝난다.
|
||||||
|
- 검증:
|
||||||
|
- 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(더미) 연결
|
||||||
|
|||||||
@@ -26,14 +26,14 @@ export const FocusTopToast = ({
|
|||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-atomic="true"
|
aria-atomic="true"
|
||||||
className="pointer-events-auto inline-flex max-w-[min(420px,92vw)] items-center gap-2 rounded-full border border-white/14 bg-black/32 px-3 py-1.5 text-xs text-white/86 shadow-[0_8px_24px_rgba(2,6,23,0.28)] backdrop-blur-md"
|
className="pointer-events-auto inline-flex min-h-10 max-w-[min(480px,94vw)] items-center gap-2.5 rounded-full border border-white/14 bg-black/36 px-4 py-2 text-sm text-white/88 shadow-[0_10px_28px_rgba(2,6,23,0.3)] backdrop-blur-md"
|
||||||
>
|
>
|
||||||
<span className="truncate">{message}</span>
|
<span className="truncate">{message}</span>
|
||||||
{actionLabel ? (
|
{actionLabel ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onAction}
|
onClick={onAction}
|
||||||
className="shrink-0 text-xs font-medium text-white/92 underline underline-offset-2 transition-colors hover:text-white"
|
className="shrink-0 text-sm font-semibold text-white/92 underline underline-offset-2 transition-colors hover:text-white"
|
||||||
>
|
>
|
||||||
{actionLabel}
|
{actionLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -143,14 +143,11 @@ const resolveInitialTimerLabel = (
|
|||||||
|
|
||||||
export const SpaceWorkspaceWidget = () => {
|
export const SpaceWorkspaceWidget = () => {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const storedSelection = useMemo(() => readStoredWorkspaceSelection(), []);
|
|
||||||
const roomQuery = searchParams.get('room');
|
const roomQuery = searchParams.get('room');
|
||||||
const goalQuery = searchParams.get('goal');
|
const goalQuery = searchParams.get('goal')?.trim() ?? '';
|
||||||
const soundQuery = searchParams.get('sound');
|
const soundQuery = searchParams.get('sound');
|
||||||
const timerQuery = searchParams.get('timer');
|
const timerQuery = searchParams.get('timer');
|
||||||
const storedGoal = storedSelection.goal?.trim() ?? '';
|
|
||||||
const hasQueryOverrides = Boolean(roomQuery || goalQuery || soundQuery || timerQuery);
|
const hasQueryOverrides = Boolean(roomQuery || goalQuery || soundQuery || timerQuery);
|
||||||
const canOfferResume = storedGoal.length > 0 && !hasQueryOverrides;
|
|
||||||
const {
|
const {
|
||||||
thoughts,
|
thoughts,
|
||||||
thoughtCount,
|
thoughtCount,
|
||||||
@@ -162,17 +159,17 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
setThoughtCompleted,
|
setThoughtCompleted,
|
||||||
} = useThoughtInbox();
|
} = useThoughtInbox();
|
||||||
|
|
||||||
const initialRoomId = resolveInitialRoomId(roomQuery, storedSelection.sceneId);
|
const initialRoomId = resolveInitialRoomId(roomQuery, undefined);
|
||||||
const initialRoom = getRoomById(initialRoomId) ?? ROOM_THEMES[0];
|
const initialRoom = getRoomById(initialRoomId) ?? ROOM_THEMES[0];
|
||||||
const initialGoal = goalQuery?.trim() ?? '';
|
const initialGoal = goalQuery;
|
||||||
const initialSoundPresetId = resolveInitialSoundPreset(
|
const initialSoundPresetId = resolveInitialSoundPreset(
|
||||||
soundQuery,
|
soundQuery,
|
||||||
storedSelection.soundPresetId,
|
undefined,
|
||||||
initialRoom.recommendedSoundPresetId,
|
initialRoom.recommendedSoundPresetId,
|
||||||
);
|
);
|
||||||
const initialTimerLabel = resolveInitialTimerLabel(
|
const initialTimerLabel = resolveInitialTimerLabel(
|
||||||
timerQuery,
|
timerQuery,
|
||||||
storedSelection.timerPresetId,
|
undefined,
|
||||||
initialRoom.recommendedTimerPresetId,
|
initialRoom.recommendedTimerPresetId,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -181,10 +178,12 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel);
|
const [selectedTimerLabel, setSelectedTimerLabel] = useState(initialTimerLabel);
|
||||||
const [goalInput, setGoalInput] = useState(initialGoal);
|
const [goalInput, setGoalInput] = useState(initialGoal);
|
||||||
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
|
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
|
||||||
const [showResumePrompt, setShowResumePrompt] = useState(canOfferResume);
|
const [resumeGoal, setResumeGoal] = useState('');
|
||||||
|
const [showResumePrompt, setShowResumePrompt] = useState(false);
|
||||||
|
const [hasHydratedSelection, setHasHydratedSelection] = useState(false);
|
||||||
const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({
|
const [selectionOverride, setSelectionOverride] = useState<SelectionOverride>({
|
||||||
sound: Boolean(storedSelection.override?.sound),
|
sound: false,
|
||||||
timer: Boolean(storedSelection.override?.timer),
|
timer: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -234,6 +233,41 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
}
|
}
|
||||||
}, [selectionOverride.sound, selectionOverride.timer, setSelectedPresetId]);
|
}, [selectionOverride.sound, selectionOverride.timer, setSelectedPresetId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedSelection = readStoredWorkspaceSelection();
|
||||||
|
const restoredSelectionOverride: SelectionOverride = {
|
||||||
|
sound: Boolean(storedSelection.override?.sound),
|
||||||
|
timer: Boolean(storedSelection.override?.timer),
|
||||||
|
};
|
||||||
|
|
||||||
|
setSelectionOverride(restoredSelectionOverride);
|
||||||
|
|
||||||
|
if (!roomQuery && storedSelection.sceneId && getRoomById(storedSelection.sceneId)) {
|
||||||
|
setSelectedRoomId(storedSelection.sceneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timerQuery) {
|
||||||
|
const restoredTimerLabel = resolveTimerLabelFromPresetId(storedSelection.timerPresetId);
|
||||||
|
|
||||||
|
if (restoredTimerLabel) {
|
||||||
|
setSelectedTimerLabel(restoredTimerLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!soundQuery && storedSelection.soundPresetId && SOUND_PRESETS.some((preset) => preset.id === storedSelection.soundPresetId)) {
|
||||||
|
setSelectedPresetId(storedSelection.soundPresetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoredGoal = storedSelection.goal?.trim() ?? '';
|
||||||
|
|
||||||
|
if (!goalQuery && restoredGoal.length > 0 && !hasQueryOverrides) {
|
||||||
|
setResumeGoal(restoredGoal);
|
||||||
|
setShowResumePrompt(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasHydratedSelection(true);
|
||||||
|
}, [goalQuery, hasQueryOverrides, roomQuery, setSelectedPresetId, soundQuery, timerQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyRecommendedSelections(selectedRoomId);
|
applyRecommendedSelections(selectedRoomId);
|
||||||
}, [applyRecommendedSelections, selectedRoomId]);
|
}, [applyRecommendedSelections, selectedRoomId]);
|
||||||
@@ -323,11 +357,15 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasHydratedSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel);
|
const timerPresetId = resolveTimerPresetIdFromLabel(selectedTimerLabel);
|
||||||
const normalizedGoal = goalInput.trim().length > 0
|
const normalizedGoal = goalInput.trim().length > 0
|
||||||
? goalInput.trim()
|
? goalInput.trim()
|
||||||
: showResumePrompt
|
: showResumePrompt
|
||||||
? storedGoal
|
? resumeGoal
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
@@ -340,7 +378,7 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
override: selectionOverride,
|
override: selectionOverride,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [goalInput, selectedRoomId, selectedTimerLabel, selectedPresetId, selectionOverride, showResumePrompt, storedGoal]);
|
}, [goalInput, hasHydratedSelection, resumeGoal, selectedRoomId, selectedTimerLabel, selectedPresetId, selectionOverride, showResumePrompt]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-dvh overflow-hidden text-white">
|
<div className="relative h-dvh overflow-hidden text-white">
|
||||||
@@ -373,11 +411,11 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
onGoalChipSelect={handleGoalChipSelect}
|
onGoalChipSelect={handleGoalChipSelect}
|
||||||
onStart={handleStart}
|
onStart={handleStart}
|
||||||
resumeHint={
|
resumeHint={
|
||||||
showResumePrompt
|
showResumePrompt && resumeGoal
|
||||||
? {
|
? {
|
||||||
goal: storedGoal,
|
goal: resumeGoal,
|
||||||
onResume: () => {
|
onResume: () => {
|
||||||
setGoalInput(storedGoal);
|
setGoalInput(resumeGoal);
|
||||||
setSelectedGoalId(null);
|
setSelectedGoalId(null);
|
||||||
setShowResumePrompt(false);
|
setShowResumePrompt(false);
|
||||||
setWorkspaceMode('focus');
|
setWorkspaceMode('focus');
|
||||||
|
|||||||
Reference in New Issue
Block a user