# Micro Fun Patch Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** spec의 짧은 💡 항목 3개를 묶어 도감 인지 / 캐릭터 살아있음 / 환경 톤을 한 번에 끌어올린다.

**Architecture:**
- 데이터 변경·새 의존성 없음. 기존 컴포넌트(`ElementsScreen`, `CharacterSprite`, `App`)에 작은 헬퍼와 prop을 추가.
- 시간대 컬러는 `App.tsx`에서 단일 source of truth로 계산해 root background에 적용. CSS-in-JS 동적 스타일.
- 캐릭터 idle mood는 ElementsScreen 안에서 입력 비활성 시간을 추적하다가 일정 시간 후 sprite에 `mood="idle"` prop 전달. CharacterSprite가 추가 keyframes를 합성.
- 미발견 도감은 기존 `tierGroups`를 확장해 미발견 원소도 포함시키고, 새 헬퍼 `getRecipeHintForElement`로 재료 emoji 힌트를 주입.

**Tech Stack:** Vite 5, React 18, TypeScript 5, @emotion/react. 검증은 `npm run typecheck` + 브라우저 수동 스모크.

**상위 spec:** `docs/superpowers/specs/2026-04-28-fun-engagement-overhaul-design.md` 의 A-5 / F-4 / F-5 항목.

---

## Sanity Check Before You Start

본 plan 작성 시점에 다음이 사실로 확인됨. 실제 구현 시 어긋나면 멈추고 보고 (BLOCKED):

- `src/components/screens/ElementsScreen.tsx`의 `undiscoveredCardStyle`/`undiscoveredNameStyle`은 단순 `???` 만 출력 (line ~1270)
- `tierGroups`는 `discoveredElementList`만 필터링해서 미발견 카드는 화면에 안 보임 (line ~1465)
- `CharacterSprite`는 ambient 애니메이션(`fireFlicker`, `rasterFloat` 등)만 있고 idle/yawn 같은 mood 분기 없음
- `App.tsx`의 `rootStyle`은 `background-color: #f7f8fa` 고정값. 시간대 처리 없음
- B-2 탭-투-수확은 이미 구현됨 (`harvestReady = progress >= 0.75`, line ~1627). 본 plan에서는 다루지 않음

---

## File Structure

| 파일 | 역할 | 변경 종류 |
|---|---|---|
| `src/lib/timeOfDay.ts` | 시간대 → 팔레트 매핑 + React hook | **Create** |
| `src/lib/useIdleMood.ts` | 사용자 비활성 추적 hook | **Create** |
| `src/components/screens/ElementsScreen.tsx` | tierGroups 확장 + 미발견 카드 힌트 + idle 시 sprite wrapper | **Modify** |
| `src/App.tsx` | `useTimeOfDay()` 호출 → rootStyle 동적 배경 | **Modify** |

> **CharacterSprite는 건드리지 않습니다.** idle 모션은 ElementsScreen에서 sprite를 감싸는 wrapper div에 CSS animation을 거는 방식 — sprite 내부 구조에 의존하지 않음.

---

## Task 1: F-5 시간대 톤 변화

**Files:**
- Create: `src/lib/timeOfDay.ts`
- Modify: `src/App.tsx` (rootStyle 동적화)

**규칙:**
- 시간대 4단계 (디바이스 로컬 시간 기준):
  - 05:00 ~ 08:59 — `dawn` (따뜻한 크림)
  - 09:00 ~ 16:59 — `day` (현재 톤 유지: 푸른 회백)
  - 17:00 ~ 19:59 — `dusk` (피치 톤)
  - 20:00 ~ 04:59 — `night` (딥 네이비)
- 5분마다 재계산해서 톤 전환

- [ ] **Step 1: timeOfDay 유틸 생성**

새 파일 `src/lib/timeOfDay.ts`:

```ts
import { useEffect, useState } from 'react';

export type TimeOfDay = 'dawn' | 'day' | 'dusk' | 'night';

export interface TimeOfDayPalette {
  background: string;
  contentTint: string;
}

const PALETTES: Record<TimeOfDay, TimeOfDayPalette> = {
  dawn:  { background: '#fff6ec', contentTint: 'rgba(255, 196, 138, 0.05)' },
  day:   { background: '#f7f8fa', contentTint: 'rgba(255, 255, 255, 0)' },
  dusk:  { background: '#fff0e6', contentTint: 'rgba(255, 138, 92, 0.06)' },
  night: { background: '#101728', contentTint: 'rgba(124, 138, 255, 0.04)' },
};

export function getTimeOfDay(date: Date = new Date()): TimeOfDay {
  const h = date.getHours();
  if (h >= 5 && h < 9) return 'dawn';
  if (h >= 9 && h < 17) return 'day';
  if (h >= 17 && h < 20) return 'dusk';
  return 'night';
}

export function getPalette(tod: TimeOfDay): TimeOfDayPalette {
  return PALETTES[tod];
}

const TICK_MS = 5 * 60 * 1000;

export function useTimeOfDay(): TimeOfDay {
  const [tod, setTod] = useState<TimeOfDay>(() => getTimeOfDay());
  useEffect(() => {
    const id = window.setInterval(() => {
      setTod((prev) => {
        const next = getTimeOfDay();
        return next === prev ? prev : next;
      });
    }, TICK_MS);
    return () => window.clearInterval(id);
  }, []);
  return tod;
}
```

- [ ] **Step 2: App.tsx의 rootStyle을 팔레트로 분기**

`src/App.tsx` import 블록에 추가:

```tsx
import { useTimeOfDay, getPalette } from './lib/timeOfDay';
```

기존 `rootStyle` 함수를 다음으로 교체:

```tsx
const rootStyle = (shaking: boolean, background: string) => css`
  width: 100%;
  height: 100vh;
  display: flex;
  flex-direction: column;
  background-color: ${background};
  overflow: hidden;
  font-family:
    'Pretendard',
    -apple-system,
    BlinkMacSystemFont,
    sans-serif;
  animation: ${shaking ? css`${legendaryShake} 0.42s ease-out` : 'none'};
  transition: background-color 1.5s ease-in-out;
`;
```

`App` 함수 본문에서 `useIdleTick();` 다음에 추가:

```tsx
  const timeOfDay = useTimeOfDay();
  const palette = getPalette(timeOfDay);
```

JSX 루트 div 변경:

```tsx
    <div css={rootStyle(shaking, palette.background)}>
```

- [ ] **Step 3: typecheck**

```bash
npm run typecheck
```

기대: PASS.

- [ ] **Step 4: 브라우저 수동 검증**

```bash
npm run dev
```

확인:
1. 일반 접속 → 현재 시간대 톤 적용 (예: 14시 = day = 기존 #f7f8fa)
2. 디바이스 시간을 변경하거나, 콘솔에서 `localStorage.setItem('debug-hour', '22')` 등 디버그 트릭 사용 — 본 plan 외 코드 변경 없이 직접 검증이 어려우면, 임시로 `useTimeOfDay`의 초기값을 `'night'` 등으로 하드코딩해 모든 4톤을 눈으로 확인 후 원복.
3. 톤 전환 시 1.5초 fade transition

- [ ] **Step 5: 커밋**

```bash
git add src/lib/timeOfDay.ts src/App.tsx
git commit -m "$(cat <<'EOF'
feat: tint app background by time of day (F-5)

Adds dawn/day/dusk/night palette derived from device-local hour. Root
background fades over 1.5s on transitions; recalculated every 5 minutes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: F-4 캐릭터 미세 인터랙션 (idle mood)

**Files:**
- Create: `src/lib/useIdleMood.ts`
- Modify: `src/components/screens/ElementsScreen.tsx` (sprite wrapper에 idle 애니메이션 부착)

**규칙:**
- 사용자 입력(pointerdown/touchstart/keydown/scroll) 기준 30초 비활성 시 mood='idle'
- idle 시 sprite를 감싸는 wrapper div가 8초 주기 yawn 애니메이션 (살짝 위로 떴다 가라앉음)
- 입력 발생하면 즉시 mood='awake' 복귀, 애니메이션 정지
- CharacterSprite 내부 코드는 변경하지 않음. wrapper 방식이라 sprite 구조에 영향 없음

- [ ] **Step 1: useIdleMood hook 생성**

새 파일 `src/lib/useIdleMood.ts`:

```ts
import { useEffect, useState } from 'react';

const IDLE_AFTER_MS = 30_000;
const TICK_MS = 2_000;

export type Mood = 'awake' | 'idle';

const ACTIVITY_EVENTS: Array<keyof WindowEventMap> = [
  'pointerdown',
  'touchstart',
  'keydown',
  'scroll',
  'wheel',
];

export function useIdleMood(idleAfterMs: number = IDLE_AFTER_MS): Mood {
  const [mood, setMood] = useState<Mood>('awake');

  useEffect(() => {
    let lastActivity = Date.now();
    const onActivity = () => {
      lastActivity = Date.now();
      setMood((prev) => (prev === 'awake' ? prev : 'awake'));
    };
    ACTIVITY_EVENTS.forEach((evt) =>
      window.addEventListener(evt, onActivity, { passive: true })
    );
    const tick = window.setInterval(() => {
      if (Date.now() - lastActivity >= idleAfterMs) {
        setMood((prev) => (prev === 'idle' ? prev : 'idle'));
      }
    }, TICK_MS);
    return () => {
      ACTIVITY_EVENTS.forEach((evt) => window.removeEventListener(evt, onActivity));
      window.clearInterval(tick);
    };
  }, [idleAfterMs]);

  return mood;
}
```

- [ ] **Step 2: ElementsScreen에 idle yawn wrapper 적용**

`src/components/screens/ElementsScreen.tsx` import 블록에 추가:

```ts
import { useIdleMood, type Mood } from '../../lib/useIdleMood';
```

스타일 영역(예: `spriteWrapStyle` 정의 직후)에 추가:

```ts
const idleYawnKeyframes = keyframes`
  0%, 70%, 100% { transform: translateY(0) scale(1); opacity: 1; }
  78% { transform: translateY(-1.5px) scale(1.04, 0.96); opacity: 0.94; }
  88% { transform: translateY(-2.5px) scale(1.06, 0.94); opacity: 0.88; }
  96% { transform: translateY(-0.5px) scale(1.02, 0.99); opacity: 0.97; }
`;

const idleSpriteWrapperStyle = (mood: Mood) => css`
  display: inline-block;
  width: 100%;
  height: 100%;
  animation: ${mood === 'idle' ? css`${idleYawnKeyframes} 8s ease-in-out infinite` : 'none'};
`;
```

> 만약 `keyframes`가 import되어 있지 않다면 `@emotion/react`의 import에 `keyframes` 추가:
> `import { css, keyframes } from '@emotion/react';`
> (이미 keyframes를 사용하고 있으면 import는 그대로 둠.)

`ElementsScreen` 함수 본문 상단(`const gold = useGameStore(...)` 영역 부근)에 추가:

```ts
  const idleMood = useIdleMood();
```

`activeProducers.map(...)` JSX의 `<div css={producerSpriteStyle(active)}>`을 다음으로 교체:

```tsx
                  <div css={producerSpriteStyle(active)}>
                    <div css={idleSpriteWrapperStyle(idleMood)}>
                      <CharacterSprite
                        elementId={el.id}
                        elementColor={el.color}
                        tier={el.tier}
                        size={42}
                        state={(elements[el.id] ?? 0) > 0 ? 'obtained' : 'locked'}
                      />
                    </div>
                  </div>
```

> Task 3에서는 ElementCard 안에서도 동일한 wrapper를 적용. ElementCard에 mood prop을 추가하는 부분이 Task 3 Step 2에 포함됨.

- [ ] **Step 3: typecheck**

```bash
npm run typecheck
```

기대: PASS.

- [ ] **Step 4: 브라우저 수동 검증**

```bash
npm run dev
```

확인:
1. Elements 탭 진입 → 30초 동안 어떤 입력도 안 함 → 작업장 producer sprite들이 천천히 yawn (위로 살짝 떴다 가라앉기, 8초 주기)
2. 화면 탭/스크롤 → 즉시 awake (yawn 멈춤)
3. 다시 30초 대기 → idle 진입 반복

(테스트 단축을 위해 호출을 임시 `useIdleMood(5000)`으로 낮추고 검증 후 원복.)

- [ ] **Step 5: 커밋**

```bash
git add src/lib/useIdleMood.ts src/components/screens/ElementsScreen.tsx
git commit -m "$(cat <<'EOF'
feat: add idle yawn animation when user is inactive (F-4)

After 30s without user input on Elements tab, producer sprites cycle a
subtle yawn (translateY + scale) every 8s. Resets immediately on any
pointer/touch/key/scroll event. Implemented via wrapper div so
CharacterSprite internals are untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: A-5 미발견 도감 슬롯 힌트

**Files:**
- Modify: `src/components/screens/ElementsScreen.tsx` (tierGroups 확장 + 힌트 헬퍼 + 미발견 카드 렌더)

**규칙:**
- 각 tier 섹션에서 발견된 원소 다음에, 해당 tier의 미발견 원소를 합성 가능 우선 + 최대 6개까지 노출
- 미발견 카드는 기존 `undiscoveredCardStyle` + 실루엣 sprite + 재료 힌트 ("🔥 + 💧" 또는 "🔥 + ?") 표시
- 재료 emoji는 재료 자체가 발견됐을 때만 노출, 아니면 "?" 폴백

- [ ] **Step 1: 헬퍼 함수 추가**

`src/components/screens/ElementsScreen.tsx`의 `elementMap` 상수 정의 부근(또는 파일 상단 헬퍼 영역)에 추가:

```ts
const elementMap = Object.fromEntries(elementsData.map((el) => [el.id, el]));

interface RecipeHint {
  display: string;
  craftableNow: boolean;
}

function getRecipeHintForElement(
  elementId: string,
  discoveredIds: string[],
  ownedCounts: Record<string, number>
): RecipeHint | null {
  const recipe = recipesData.find((r) => r.result === elementId);
  if (!recipe) return null;
  const [aId, bId] = recipe.ingredients;
  const aDiscovered = discoveredIds.includes(aId);
  const bDiscovered = discoveredIds.includes(bId);
  const aSym = aDiscovered ? (elementMap[aId]?.emoji ?? '?') : '?';
  const bSym = bDiscovered ? (elementMap[bId]?.emoji ?? '?') : '?';
  const sameElement = aId === bId;
  const hasA = (ownedCounts[aId] ?? 0) >= (sameElement ? 2 : 1);
  const hasB = sameElement ? hasA : (ownedCounts[bId] ?? 0) >= 1;
  return {
    display: `${aSym} + ${bSym}`,
    craftableNow: aDiscovered && bDiscovered && hasA && hasB,
  };
}
```

(`elementMap` 선언이 이미 있으면 중복 추가하지 말 것)

미발견 카드 스타일에 작은 힌트용 스타일 추가 (스타일 영역, `undiscoveredNameStyle` 옆):

```ts
const undiscoveredHintStyle = (craftable: boolean) => css`
  font-size: 9px;
  font-weight: 800;
  color: ${craftable ? adaptive.blue500 : adaptive.grey400};
  letter-spacing: 0.5px;
  margin-top: 2px;
`;
```

- [ ] **Step 2: ElementCard에 미발견 힌트 + idle wrapper 렌더링 추가**

`ElementCard` 컴포넌트 props에 `mood?: Mood; hint?: RecipeHint | null;` 추가:

```tsx
interface ElementCardProps {
  el: ElementData;
  state: ElementState;
  count: number;
  level: number;
  onSelect: (el: ElementData) => void;
  isAutomated?: boolean;
  isTutorialTarget?: boolean;
  mood?: Mood;               // Task 2에서 정의
  hint?: RecipeHint | null;  // 본 Task에서 추가
}
```

(`Mood` import 확인: `import { useIdleMood, type Mood } from '../../lib/useIdleMood';` — Task 2에서 이미 추가됨)

`state === 'undiscovered'` 분기를 다음으로 교체:

```tsx
  if (state === 'undiscovered') {
    return (
      <div css={undiscoveredCardStyle}>
        <div css={spriteWrapStyle}>
          <div css={idleSpriteWrapperStyle(mood ?? 'awake')}>
            <CharacterSprite
              elementId={el.id}
              elementColor={el.color}
              tier={el.tier}
              size={56}
              state="undiscovered"
            />
          </div>
        </div>
        <span css={undiscoveredNameStyle}>???</span>
        {hint && (
          <span css={undiscoveredHintStyle(hint.craftableNow)}>{hint.display}</span>
        )}
      </div>
    );
  }
```

`state === 'locked'` 분기도 일관성 위해 동일하게 sprite wrapper 적용:

```tsx
  if (state === 'locked') {
    return (
      <div css={lockedCardStyle}>
        <span css={lockIconStyle}>🔒</span>
        <div css={spriteWrapStyle}>
          <div css={idleSpriteWrapperStyle(mood ?? 'awake')}>
            <CharacterSprite
              elementId={el.id}
              elementColor={el.color}
              tier={el.tier}
              size={56}
              state="locked"
            />
          </div>
        </div>
        <span css={elementNameStyle}>{el.name}</span>
      </div>
    );
  }
```

`state === 'obtained'` (default) 분기의 `<div css={spriteWrapStyle}>` 안 CharacterSprite도 동일하게 wrapper로 감쌈:

```tsx
      <div css={spriteWrapStyle}>
        <div css={idleSpriteWrapperStyle(mood ?? 'awake')}>
          <CharacterSprite elementId={el.id} elementColor={el.color} tier={el.tier} size={56} state="obtained" />
        </div>
      </div>
```

- [ ] **Step 3: tierGroups를 확장해 미발견 원소 포함**

`ElementsScreen` 본문에서 기존 `tierGroups` 정의를 다음으로 교체:

```ts
  const MAX_UNDISCOVERED_PER_TIER = 6;

  const tierGroups = [1, 2, 3, 4, 5].map((tier) => {
    const tierElements = elementsData.filter((el) => el.tier === tier);
    const owned = tierElements.filter((el) => discoveredElementIds.includes(el.id));
    const undiscovered = tierElements.filter((el) => !discoveredElementIds.includes(el.id));

    const undiscoveredWithHints = undiscovered
      .map((el) => ({ el, hint: getRecipeHintForElement(el.id, discoveredElementIds, elements) }))
      .sort((a, b) => {
        const aCraftable = a.hint?.craftableNow ? 1 : 0;
        const bCraftable = b.hint?.craftableNow ? 1 : 0;
        if (aCraftable !== bCraftable) return bCraftable - aCraftable;
        const aHasHint = a.hint ? 1 : 0;
        const bHasHint = b.hint ? 1 : 0;
        return bHasHint - aHasHint;
      })
      .slice(0, MAX_UNDISCOVERED_PER_TIER);

    return {
      tier,
      owned,
      undiscovered: undiscoveredWithHints,
    };
  }).filter(({ owned, undiscovered }) => owned.length > 0 || undiscovered.length > 0);
```

기존 tierGroups 렌더링 JSX를 다음으로 교체:

```tsx
      {tierGroups.map(({ tier, owned, undiscovered }) => (
        <div key={tier} css={tierSectionStyle}>
          <div css={tierLabelStyle}>{TIER_LABELS[tier]}</div>
          <div css={elementGridStyle}>
            {owned.map((el) => (
              <ElementCard
                key={el.id}
                el={el}
                state="obtained"
                count={elements[el.id] ?? 0}
                level={elementLevels[el.id] ?? 0}
                onSelect={setSelectedEl}
                isAutomated={el.tier <= tierAutomation.unlockedTier}
                isTutorialTarget={el.id === firstObtainedId}
                mood={idleMood}
              />
            ))}
            {undiscovered.map(({ el, hint }) => (
              <ElementCard
                key={el.id}
                el={el}
                state="undiscovered"
                count={0}
                level={0}
                onSelect={setSelectedEl}
                hint={hint}
                mood={idleMood}
              />
            ))}
          </div>
        </div>
      ))}
```

> 만약 첫 1티어부터 모든 카드가 발견된 신규 유저가 아니라면 (이미 진행한 게임 데이터 보유), 미발견 미니카드가 갑자기 등장하면서 시각적 변화가 큼 — 정상. 콜렉션 갈증을 만드는 의도.

- [ ] **Step 4: typecheck**

```bash
npm run typecheck
```

기대: PASS.

- [ ] **Step 5: 브라우저 수동 검증**

```bash
npm run dev
```

확인:
1. 새 게임 또는 기존 진행 → Elements 화면 하단 tier 섹션
2. 각 tier에서 발견 카드 다음에 실루엣 카드들이 보임
3. 실루엣 카드 아래 "🔥 + 💧" 같은 재료 emoji (양쪽 다 발견됐고 보유량 있으면 파란색, 아니면 회색)
4. 미발견 재료가 있으면 "🔥 + ?" 형식으로 표시
5. 발견 후 → 해당 카드는 owned 섹션으로 자연스럽게 이동

- [ ] **Step 6: 커밋**

```bash
git add src/components/screens/ElementsScreen.tsx
git commit -m "$(cat <<'EOF'
feat: surface recipe hints on undiscovered codex slots (A-5)

Each tier section now shows up to 6 undiscovered silhouettes after the owned
cards, sorted by craftable-now > known-ingredients > unknown. Hint is the
two-ingredient emoji pair, with "?" when an ingredient has not been found.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Final Verification

- [ ] **Step 1: 통합 typecheck**

```bash
npm run typecheck
```

기대: PASS.

- [ ] **Step 2: 통합 빌드**

```bash
npm run build
```

기대: 성공. 산출물 `dist/`.

- [ ] **Step 3: 통합 스모크 테스트**

```bash
npm run dev
```

체크리스트:
- [ ] 시간대 톤이 현재 시간에 맞게 적용 (낮 시간대면 기존 흰빛, 밤이면 네이비)
- [ ] 30초 입력 안 하면 캐릭터 sprite가 yawn + 깜빡임
- [ ] 입력하면 즉시 awake로 복귀
- [ ] Elements 화면 tier 섹션에 미발견 실루엣 카드 + 재료 힌트
- [ ] 새 원소를 합성·발견 → 카드가 owned 섹션으로 이동, hint 카드는 줄어듦
- [ ] 모든 미발견 카드 클릭 시 기존 DetailPanel은 안 열려도 OK (state="undiscovered" 분기에 onSelect 호출 없음)
- [ ] typecheck/build 모두 PASS

- [ ] **Step 4: 커밋 로그 확인**

```bash
git log --oneline -6
```

기대: 본 plan의 3개 커밋이 차례로 보임.

---

## Self-Review Notes

본 plan에서 의도적으로 다루지 않은 항목:
- D-4 도감 URL 공유 — 별도 plan
- 도감 카드 클릭 시 DetailPanel 변형 (미발견 전용 UI) — A-5 v0에는 부재
- night 톤일 때 contentTint를 실제로 어디에 합성할지 — Task 1에서는 background만 사용, 향후 폴리시에서 contentTint 활용 가능
- idle mood가 FusionScreen/EvolutionScreen 등 다른 화면에도 전파되는지 — 본 plan은 ElementsScreen만 (가장 sprite 노출 많은 화면)

본 plan에서 도입하는 새 의존성: 없음.
