---
name: expo-ui
description: "Dotori Expo UI 컴포넌트 생성·수정 전문 스킬. Unistyles 3 + Reanimated 4 + spring 토큰 + Gesture Handler v2 + expo-haptics 패턴을 적용한 2026 프리미엄 품질 컴포넌트를 만든다. /expo-ui 또는 'Expo 컴포넌트', '컴포넌트 만들어' 등의 자연어에도 반응."
trigger: manual
---

# Expo UI Skill — Dotori 2026 컴포넌트 전문가

Dotori의 **Expo 55 + React Native 0.83 + Unistyles 3** 스택에서  
2026 프리미엄 품질 컴포넌트를 제로부터 만들거나 기존 컴포넌트를 업그레이드한다.

## 절대 규칙 (위반 시 BLOCK)

| 금지 | 대체 |
|------|------|
| React Native core `StyleSheet.create` | `StyleSheet.create` from `react-native-unistyles` |
| raw hex `#D97757` in styles | `theme.colors.accent.primary` |
| raw `withSpring({damping:18})` | `withSpring(target, theme.motion.spring.snappy)` |
| `new Animated.Value` | `useSharedValue` (Reanimated 4) |
| `Animated.timing/loop` | `withTiming / withRepeat` |
| `Haptics.impactAsync()` without gate | `if (hapticsEnabled) Haptics.impactAsync(...)` |
| `className` style props | Unistyles only |
| `useNativeDriver: false` | `useNativeDriver: true` 또는 Reanimated |
| 이모지 in UI label | Lucide RN 아이콘 |

---

## 파일 구조 템플릿

```
src-next/components/
  common/           ← 재사용 기본 프리미티브
  navigation/       ← 탭바/헤더 전용
  map/              ← 지도 전용
  wow/              ← AI Ask / Source-check 전용
  academy/          ← 학원 탭 전용
```

---

## 컴포넌트 보일러플레이트

```tsx
// src-next/components/common/MyComponent.tsx
import React from 'react'
import { Pressable, View } from 'react-native'
import Animated, {
  useSharedValue, useAnimatedStyle, withSpring
} from 'react-native-reanimated'
import * as Haptics from 'expo-haptics'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { useInteractionPolicy } from '../../lib/interactionPolicy'
import { AppText } from '../../lib/uiAdapter'

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

interface MyComponentProps {
  onPress: () => void
  label: string
  disabled?: boolean
}

export function MyComponent({ onPress, label, disabled = false }: MyComponentProps) {
  const { theme } = useUnistyles()
  const { hapticsEnabled, reducedMotion } = useInteractionPolicy()
  const scale = useSharedValue(1)

  const animStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }))

  const onPressIn = () => {
    if (disabled || reducedMotion) return
    scale.value = withSpring(theme.motion.press.default, theme.motion.spring.snappy)
  }

  const onPressOut = () => {
    if (reducedMotion) return
    scale.value = withSpring(1, theme.motion.spring.snappy)
  }

  const handlePress = () => {
    if (disabled) return
    if (hapticsEnabled) Haptics.selectionAsync().catch(() => undefined)
    onPress()
  }

  return (
    <AnimatedPressable
      style={[styles.container, animStyle]}
      onPress={handlePress}
      onPressIn={onPressIn}
      onPressOut={onPressOut}
      disabled={disabled}
      accessibilityRole="button"
      accessibilityLabel={label}
    >
      <AppText variant="label" style={styles.label}>{label}</AppText>
    </AnimatedPressable>
  )
}

const styles = StyleSheet.create((theme) => ({
  container: {
    height: theme.componentTokens.controlHeight.buttonMd,
    borderRadius: theme.componentTokens.radius.buttonMd,
    backgroundColor: theme.colors.accent.primary,
    justifyContent: 'center',
    alignItems: 'center',
    paddingHorizontal: 16,
  },
  label: {
    color: theme.colors.text.onAccent,
  },
}))
```

---

## 토큰 레퍼런스

### 색상 (`theme.colors.*`)
```ts
bg.{canvas|base|surface|elevated|overlay}
text.{primary|secondary|tertiary|muted|disabled|onAccent}
accent.{primary|hover|pressed|muted}     // #D97757 warm persimmon
border.{subtle|default|strong}
status.{success|warning|danger|info}
accentSoft.{bg|bgStrong|border|borderStrong}
```

### 컴포넌트 토큰 (`theme.componentTokens.*`)
```ts
radius.{buttonSm|buttonMd|buttonLg|chipSm|chipMd|surfaceSm|surfaceMd|surfaceLg|cardXl|hero|pill}
controlHeight.{buttonSm|buttonMd|buttonLg|chipSm|chipMd|inputMd}
shadow.{elevated|soft|ambient|brandGlow|hero|floatingDock}
glass.{regular|clear}
```

### 타이포그래피 (`theme.typography.*`)
```ts
typography.{display|h1|h2|h3|bodyLg|body|bodySm|label|caption|mono|monoSm}
// ⚠ 이하 deprecated aliases — 마이그레이션 대상:
//   micro→caption, kicker→label, heading→h3, title→h2, brand→h1
```

### 스프링 (`theme.motion.spring.*`)
```ts
spring.micro    // mass:1, damping:24, stiffness:300 — 아이콘 팝, 뱃지 등장
spring.snappy   // d:18, s:220 — 칩 토글, 아이콘 스왑
spring.default  // d:18, s:220 — 선택 상태, 카드 포커스
spring.gentle   // d:14, s:140 — 콘텐츠 reveal, 소프트 등장
spring.drawer   // d:22, s:260 — 사이드 드로어
spring.sheet    // d:18, s:120 — 바텀시트, 모달
```

### 프레스 스케일 (`theme.motion.press.*`)
```ts
press.subtle   // 0.99
press.default  // 0.985
press.strong   // 0.97
```

---

## GlassPanel 사용법

```tsx
import { GlassPanel } from './GlassPanel'

// 바/컨트롤/검색바/FAB → variant="regular"
<GlassPanel variant="regular" style={styles.bar}>
  {/* controls */}
</GlassPanel>

// 플로팅 오버레이/패널 → variant="clear"
<GlassPanel variant="clear" style={styles.overlay}>
  {/* overlay content */}
</GlassPanel>
```

---

## AppText 사용법

```tsx
import { AppText } from '../../lib/uiAdapter'

<AppText variant="h2">제목</AppText>
<AppText variant="body" style={{ color: theme.colors.text.secondary }}>본문</AppText>
<AppText variant="caption" style={{ textTransform: 'uppercase', letterSpacing: 0.8 }}>
  SIGNAL  {/* eyebrow label — 12px 이하는 ALL CAPS + letterSpacing */}
</AppText>
```

---

## Gesture Handler v2 패턴

```tsx
import { Gesture, GestureDetector } from 'react-native-gesture-handler'

// Tap gesture (Pressable 대신 복잡한 gesture composition 시)
const tapGesture = Gesture.Tap()
  .onEnd(() => { /* handle tap */ })

// Long press
const longPress = Gesture.LongPress()
  .minDuration(400)
  .onActivated(() => { /* handle */ })

// Simultaneous pan + tap
const composed = Gesture.Simultaneous(tapGesture, panGesture)

<GestureDetector gesture={composed}>
  <Animated.View style={animStyle} />
</GestureDetector>
```

---

## 햅틱 체계

| 상황 | 코드 |
|------|------|
| Primary CTA confirm | `Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)` |
| Destructive / error | `Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error)` |
| 칩 토글 / 선택 | `Haptics.selectionAsync()` |
| 시트 오픈 / 마커 탭 | `Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)` |
| 드래그 핸들 릴리즈 | `Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)` |

항상 `if (hapticsEnabled)` 게이트 적용.

---

## Skeleton / Loading 상태

```tsx
import { Skeleton } from './Skeleton'
import { LoadingSurface } from './LoadingSurface'
import { ThinkingIndicator } from './ThinkingIndicator'

// 카드 로딩
<Skeleton width={200} height={20} borderRadius={8} />

// 전체 surface 로딩
<LoadingSurface />

// AI 스트리밍 로딩
<ThinkingIndicator />
```

---

## 접근성 필수 props

모든 인터랙티브 요소:
```tsx
accessibilityRole="button"         // 또는 "link", "checkbox", "radio", etc.
accessibilityLabel="구체적 동작 설명"
accessibilityHint="탭하면 X가 됩니다"  // optional but recommended
accessibilityState={{ disabled }}
```

터치 타겟 최소 **48×48dp**. 작은 요소는 `hitSlop={{ top:8, bottom:8, left:8, right:8 }}` 추가.

---

## 컴포넌트 완성 체크리스트

생성/수정 후 반드시 확인:

- [ ] `npx tsc --noEmit --pretty false` → 0 errors
- [ ] `rg -n "from 'react-native' .*StyleSheet|#[0-9a-fA-F]{3,6}" src-next/components/MyComponent.tsx` → 0 hits
- [ ] `rg -n "withSpring\\(\\{|withTiming\\([^,]+\\)" src-next/components/MyComponent.tsx` → 0 hits (반드시 token 사용)
- [ ] 7가지 인터랙션 상태 구현: idle / focused / pressed / loading / success / error / disabled
- [ ] `accessibilityRole` + `accessibilityLabel` 있음
- [ ] 48dp+ 터치 타겟
- [ ] `hapticsEnabled` 게이트 적용
- [ ] `reducedMotion` 적용 (모션 건너뜀)
- [ ] `npm run design:review:native` 캡처 → 비전 검수

---

## 자주 쓰는 Primitives

```
GlassPanel        src-next/components/common/GlassPanel.tsx
DotoriBrandOrb    src-next/components/common/DotoriBrandOrb.tsx
AppText           src-next/lib/uiAdapter.ts
Button            src-next/components/common/Button.tsx
Chip              src-next/components/common/Chip.tsx
Skeleton          src-next/components/common/Skeleton.tsx
ThinkingIndicator src-next/components/common/ThinkingIndicator.tsx
LoadingSurface    src-next/components/common/LoadingSurface.tsx
```
