create-saas-starter-workspace 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/workspace/.claude/skills/app-launch/SKILL.md +82 -0
  2. package/dist/workspace/.claude/skills/{release → app-update}/SKILL.md +5 -5
  3. package/dist/workspace/.claude/skills/bridge-guide/SKILL.md +43 -26
  4. package/dist/workspace/.claude/skills/eas-deploy-guide/SKILL.md +37 -79
  5. package/dist/workspace/.claude/skills/native-app-guide/SKILL.md +43 -9
  6. package/dist/workspace/.claude/skills/probe/SKILL.md +14 -2
  7. package/dist/workspace/.claude/skills/sketch/SKILL.md +14 -8
  8. package/dist/workspace/.claude/skills/store-release-guide/SKILL.md +54 -57
  9. package/dist/workspace/.claude/skills/web-launch/SKILL.md +59 -0
  10. package/dist/workspace/AGENTS.md +1 -1
  11. package/dist/workspace/START-HERE.md +3 -6
  12. package/dist/workspace/mobile/.env.example +3 -1
  13. package/dist/workspace/mobile/AGENTS.md +9 -1
  14. package/dist/workspace/mobile/README.md +1 -1
  15. package/dist/workspace/mobile/eas.json +2 -2
  16. package/dist/workspace/scripts/connect-repos.mjs +1 -1
  17. package/dist/workspace/web/.env.example +1 -1
  18. package/dist/workspace/web/AGENTS.md +8 -8
  19. package/dist/workspace/web/README.md +6 -7
  20. package/dist/workspace/web/docs/ENVIRONMENTS.md +8 -4
  21. package/dist/workspace/web/instrumentation-client.ts +4 -2
  22. package/package.json +1 -1
  23. package/src/scaffold.mjs +1 -1
  24. package/dist/workspace/.claude/skills/go-live/SKILL.md +0 -68
  25. package/dist/workspace/.claude/skills/kickoff/SKILL.md +0 -72
  26. package/dist/workspace/.claude/skills/launch/SKILL.md +0 -69
  27. package/dist/workspace/.claude/skills/preview/SKILL.md +0 -43
  28. package/dist/workspace/.claude/skills/vercel-cron/SKILL.md +0 -17
  29. package/dist/workspace/.claude/skills/warmup/SKILL.md +0 -33
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: app-launch
3
+ description: Set up App Store Connect and Play Console for the first release — developer accounts, app records, signing keys, store listing, privacy declarations — then the first manual submit-for-review. Use when the Owner is ready to ship to the stores for the first time, or invokes /app-launch.
4
+ user-invocable: true
5
+ ---
6
+
7
+ # /app-launch
8
+
9
+ 앱을 앱스토어·플레이스토어에 처음 올리기 위한 1회 출시 준비다. 개발자 계정·앱 레코드·서명 키·스토어 리스팅·프라이버시 선언을 갖추고 첫 수동 심사 제출까지 데려간다. 비개발자에게 가장 큰 난관이라, web-launch처럼 단계별로 확인받으며 진행한다.
10
+
11
+ 이 스킬은 순서와 게이트만 잡는다. 가격·정책·콘솔 절차·심사 통과 전략의 정본은 `store-release-guide`이고, EAS 빌드·제출 명령의 정본은 `eas-deploy-guide`다. 둘 다 드리프트하므로 본문에 박지 말고 그때 가이드에서 읽는다.
12
+
13
+ ## 빠른 참조 (값은 여기서 읽는다)
14
+
15
+ - 개발자 계정 비용·계정 유형, Play 비공개 테스트 게이트의 인원·기간: `store-release-guide` §1 (정책 SSOT — 본문에 다시 적지 않는다).
16
+ - 빌드·제출 명령, EAS 무료 티어 한도: `eas-deploy-guide`.
17
+ - 앱 식별자·서명·SDK 버전: `mobile/app.config.ts`·`mobile/eas.json`·`mobile/package.json` (메모리를 믿지 않는다).
18
+
19
+ ## 진행 방식 (3단계)
20
+
21
+ 1. **읽기전용 조사**: 계정 유무, 기존 자격증명, 배포된 웹의 컴플라이언스 표면(계정 삭제·트래커)을 먼저 살핀다.
22
+ 2. **일괄 플랜**: AskUserQuestion으로 결정과 비용을 한 번에 묻는다.
23
+ 3. **confirm 실행**: 비가역·돈·2FA가 걸린 일만 Owner confirm 뒤 진행한다.
24
+
25
+ ## 누가 무엇을 하나 (가역성)
26
+
27
+ - 🟢 AI / CLI: 자격증명 파일 작업, EAS 빌드·제출, `eas.json`의 public 값.
28
+ - 🟡 Owner 대시보드 (AI가 정확히 안내): 첫 Apple 로그인과 2FA, 스크린샷, App Privacy·Data Safety 라벨, Play 서비스 계정 JSON.
29
+ - 🔴 Owner 직접 (대리 불가): 카드 결제, 신원 검증, 멤버십 가입.
30
+
31
+ ## 절차 (순서가 게이트다)
32
+
33
+ ### 1. 개발자 계정 — 가장 먼저 (🔴 Owner)
34
+
35
+ 두 계정 가입이 출시 전체의 리드타임을 지배하므로, 코드 작업 전에 둘 다 시작한다. 비용·계정 유형은 `store-release-guide` §1에서 읽는다(유료·환불 불가라 한 줄 confirm을 받는다). Individual/Personal을 권장하는 이유는 D-U-N-S를 피하고 신원검증만 거치기 때문이며, 개인 법적 이름이 공개 판매자명이 된다.
36
+
37
+ 🔴 리드타임 트랩: Google 신규 개인계정은 production 전에 비공개(closed) 테스트가 강제고(인원·기간은 `store-release-guide` §1; 내부 테스트는 카운트하지 않는다), 이게 출시에서 가장 긴 지연이다. 그래서 **Google Play Console 가입을 가장 먼저** 해 둔다. Apple도 결제 후 멤버십 활성화에 시간이 걸린다.
38
+
39
+ ### 2. 앱 레코드·서명 자격증명 (🟢 대부분 자동)
40
+
41
+ EAS가 iOS 인증서·프로비저닝·APNs 키와 Android 업로드 키스토어를 자동 생성하므로 `.cer`/`.jks`를 손대지 않는다. iOS ASC API 키도 EAS 자동 생성이 기본이며, Account Holder의 1회 'Request Access' 약관 동의가 선행이다. iOS는 첫 제출이 ASC 앱 레코드를 자동 생성해 TestFlight까지 간다.
42
+
43
+ 🔴 트랩: Android 첫 AAB는 Play Console에 **수동으로 1회 업로드**해야 한다(Play API는 첫 릴리스를 만들지 못한다). 첫 자동 제출 실패는 정상이니 선고지한다. 이후부터 `eas submit -p android`가 동작한다. Play 제출용 서비스 계정 JSON은 항상 수동(🟡)이며 EAS 서버에 저장한다(`eas.json`에 시크릿 경로를 넣지 않는다). 절차는 `store-release-guide`.
44
+
45
+ ### 3. 푸시 자격증명 (앱에 푸시를 켰을 때만)
46
+
47
+ 앱은 Expo Push Token만 쓴다. iOS APNs 키는 EAS 자동(🟢)이고, Android FCM v1 서비스 계정은 Firebase Console에서 수동 생성한 뒤 EAS에 업로드한다(🟡). `google-services.json`(public·커밋), FCM SA JSON(시크릿), Play 제출 SA JSON은 각각 별개 파일이니 혼동하지 않는다. 매핑은 `store-release-guide`.
48
+
49
+ ### 4. 소셜 로그인 콘솔 (소셜을 켰을 때만, 🟡)
50
+
51
+ Google OAuth 클라이언트는 Web·iOS·Android 3종이고, 코드에는 webClientId(Web)만 넣는다. Apple은 게이트로 네이티브 idToken(기본·bundle ID만)을 권장한다. 한국 개발자는 웹 OAuth용 Services ID에 server-to-server 엔드포인트 요건이 걸리므로(시행 시점은 `store-release-guide`), 네이티브 방식이 그 부담을 피한다.
52
+
53
+ 🟡 콘솔 트랩 — 자동 계산이 아니라 손으로 맞춰야 조용히 안 깨진다:
54
+ - iOS reversed URL scheme은 플러그인에 **수동으로 붙여넣는다**(누락 시 iOS 소셜 로그인이 무한 로딩).
55
+ - SHA-1 지문은 **모든** 서명 키를 등록한다(EAS 빌드 키 포함). 일부만 등록하면 `DEVELOPER_ERROR`로 막힌다.
56
+ - 소셜을 켜면 Apple 4.8에 따라 Sign in with Apple도 동등하게 들어가므로, Supabase Apple provider 실배선까지 점검한다("Google만 켜고 Apple은 나중에"는 불가능하다). 절차는 `store-release-guide`.
57
+
58
+ ### 5. 스토어 리스팅·컴플라이언스 (🟡 수동)
59
+
60
+ 스크린샷, 연령등급(IARC), App Privacy(iOS)·Data Safety(Android) 라벨은 수동이다. 세 프라이버시 산출물(ASC 라벨·Data Safety·`PrivacyInfo.xcprivacy`)은 taxonomy가 달라 복붙이 안 되므로, 단일 데이터 인벤토리에서 각각 매핑한다.
61
+
62
+ 게이트로 점검할 항목 — 둘 다 누락이 심사에서 거절을 부른다:
63
+ - **계정 삭제 (5.1.1(v))**: 배포된 웹에 인앱 삭제 경로가 있고, 네이티브 소셜 포함 모든 로그인에서 도달 가능한지 확인한다(웹뷰 렌더는 인앱으로 인정된다).
64
+ - **ATT**: 기본은 No다. 단, 웹뷰가 로드하는 배포된 웹의 트래커(GA4·Pixel 등)는 개발자 책임이므로, 배포 웹 스크립트를 실점검한다.
65
+
66
+ 세부 기준은 `store-release-guide`.
67
+
68
+ ### 6. 첫 제출 → 공개 심사 (🔴 트랩 + confirm)
69
+
70
+ production 빌드 하나를 만들어 두 스토어 테스트 채널로 보낸다(iOS는 TestFlight, Android는 내부 트랙). 빌드·제출 명령은 `eas-deploy-guide`. 같은 production 빌드를 그대로 공개로 승급하므로 전용 preview 빌드를 따로 만들지 않는다.
71
+
72
+ 🔴 트랩: `eas submit`은 **공개 심사를 제출하지 않는다** — 테스트 채널까지만 올린다. 공개 출시는 수동 + 명시 confirm이다:
73
+ - **iOS**: App Store Connect에서 "Submit for Review".
74
+ - **Android**: Play 비공개 테스트 게이트(인원·기간은 `store-release-guide` §1)를 통과한 뒤 production으로 승급.
75
+
76
+ ## confirm 경계
77
+
78
+ 개발자 계정 가입·결제(1, 🔴), 공개 심사 제출과 production 승급(6, 🔴)은 Owner confirm을 받는다. 자격증명 파일 작업과 테스트 채널 제출은 자동(🟢)이다. 첫 Apple 로그인과 2FA·라벨·SA JSON은 Owner가 대시보드에서 진행한다(🟡, AI가 안내).
79
+
80
+ ## 검증
81
+
82
+ 성공은 두 가지가 참일 때다: production 빌드가 두 테스트 채널(TestFlight·Play 내부 트랙)에 올라 실기기에서 prod 웹을 띄우고, 컴플라이언스 게이트(계정 삭제 도달 가능·미사용 권한 없음·소셜을 켰다면 Sign in with Apple 동등)가 채워진다. 공개 심사 제출과 production 승급은 그 뒤 Owner confirm으로만 일어난다 — `eas submit`이 성공했다고 앱이 공개된 게 아니다(조용한 오해를 가르는 지점이다).
@@ -1,12 +1,12 @@
1
1
  ---
2
- name: release
3
- description: Ship an app update the right way, every time after the first launch — most product changes ship as a plain web deploy (zero store action, since the app just re-loads the deployed web), JS-only shell changes go out as an instant OTA update, and only native, config, permission, or SDK changes need a rebuild with a marketing-version bump and a store resubmit. Use when the Owner wants to release an update after launch, or invokes /release.
2
+ name: app-update
3
+ description: Ship an app update the right way every time after the first launch — the agent reads what changed and routes it to a plain web deploy, an instant OTA update, or a native rebuild with a store resubmit. Use when the Owner wants to release an update once the app is already live, or invokes /app-update.
4
4
  user-invocable: true
5
5
  ---
6
6
 
7
- # /release
7
+ # /app-update
8
8
 
9
- 출시 뒤로 계속 반복하는 흐름이다. 변경할 때마다 부른다. 변경의 성격에 따라 길이 셋으로 갈린다. 웹은 언제나 SSOT이고, 앱은 배포된 웹을 다시 띄우는 얇은 셸이다. 그래서 대부분의 변경은 스토어를 건드리지 않고 끝난다.
9
+ 출시 뒤로 계속 반복하는 흐름이다. 변경할 때마다 부른다. 웹은 언제나 SSOT이고, 앱은 배포된 웹을 다시 띄우는 얇은 셸이다. 그래서 대부분의 변경은 스토어를 건드리지 않고 끝난다. 변경의 성격에 따라 길이 셋으로 갈린다.
10
10
 
11
11
  ## 1. 무엇이 바뀌었나 — 길 가르기
12
12
 
@@ -42,7 +42,7 @@ user-invocable: true
42
42
  `eas submit`은 테스트 채널까지만 올린다. 공개 심사 제출은 하지 않는다. 공개 출시는 심사·공개가 걸려 되돌리기 어렵다. 그래서 Owner가 콘솔에서 직접 + 명시 confirm으로 한다.
43
43
 
44
44
  - **iOS** — App Store Connect에서 "Submit for Review".
45
- - **Android** — production 트랙으로 승급. 신규 개인계정은 비공개 테스트 12명·14일 게이트를 통과한 뒤에야 가능하다.
45
+ - **Android** — production 트랙으로 승급. 신규 개인계정은 비공개 테스트 게이트를 통과해야 production이 열린다(테스터 수·기간·면제 조건은 `store-release-guide`).
46
46
 
47
47
  콘솔 절차·테스트 게이트·심사 통과 전략·컴플라이언스는 `store-release-guide`.
48
48
 
@@ -6,66 +6,83 @@ user-invocable: false
6
6
 
7
7
  # 앱↔웹 대화 규격 (ssb 계약)
8
8
 
9
- 앱과 웹은 정해진 형식의 메시지로만 대화한다. 형식 정의는 양쪽이 똑같은 파일 하나(`src/bridge/contract.ts` + `reader.ts`)로 공유한다. 어긋나면 워크스페이스 루트의 `pnpm run doctor`가 잡아낸다.
9
+ 앱과 웹은 `ns: "ssb"`를 단 정해진 형식의 메시지로만 대화한다. 계약의 원본은 `ssb/`에 있고, 같은 내용이 `web/lib/bridge/`와 `mobile/src/bridge/`에 바이트 단위로 복사된다. 세 사본이 어긋나면 워크스페이스 루트의 `pnpm run doctor`가 잡는다. 메시지의 네이티브 측 처리·셸 배선은 `native-app-guide`가 맡는다.
10
+
11
+ ## 빠른 참조 (값은 여기서 읽는다)
12
+
13
+ - 봉투·메시지 타입(`MSG`)·zod 스키마·불변식 헤더: `ssb/contract.ts`(SSOT). 메시지 목록을 외워 쓰지 말고 여기서 읽는다.
14
+ - tolerant 파서: `ssb/reader.ts`. 라우팅·핸드셰이크: `mobile/src/bridge/{router,capabilities,messaging}.ts`.
15
+ - 앱이 광고하는 capability 집합: `mobile/src/bridge/capabilities.ts`의 `APP_CAPABILITIES`. v1 토큰을 본문에 박지 않는다.
16
+ - 세션 핸드오프 nonce 규칙: `mobile/AGENTS.md` 불변식 2. 웹 어댑터 라우트: `mobile/docs/web-adapter/`.
10
17
 
11
18
  ## 봉투 & 전달 방식
12
19
 
13
20
  봉투: `{ ns: "ssb", v: number, type: string, id?: string, payload?: object }`
14
21
 
15
- - `ns: "ssb"`로 네임스페이스를 잡아, 페이지의 무관한 postMessage 트래픽을 무시한다. hotdeal의 `"hotdeal-bridge/1"`이 아니다. `ssb`가 이 템플릿의 SSOT다.
22
+ - `ns: "ssb"`로 네임스페이스를 잡아, 페이지의 무관한 postMessage 트래픽을 무시한다. `ssb`가 이 템플릿의 SSOT다(hotdeal의 `"hotdeal-bridge/1"`이 아니다).
16
23
  - 웹 → 앱: 웹이 `window.ReactNativeWebView.postMessage(JSON.stringify(envelope))`를 호출하면, `Host.onMessage`가 raw 문자열을 `router.handleInbound(raw, ctx)`에 넘긴다.
17
24
  - 앱 → 웹: `ctx.inject(buildReceiveInjection(msg))`가 `window.__ssbBridge?.receive(<json>)`를 호출하고 `CustomEvent("ssb:message", {detail})`를 디스패치한다(try/catch, 문 끝 `true;`).
18
25
  - `ReactNativeWebView.postMessage`를 쓰므로 CSP와 무관하다. `injectedJavaScriptForMainFrameOnly={true}`로 메인 프레임만 대상으로 한다.
19
- - 가드레일: WebView에 `limitsNavigationsToAppBoundDomains` / iOS `WKAppBoundDomains`를 설정하지 말 것. onMessage·injectedJavaScript가 소리 없이 죽어 브릿지 전체가 먹통이 된다.
20
26
 
21
- ## 불변식
27
+ `BridgeContext` = `{ appVersion, platform, webOrigin, inject(js), onNeedLogin(redirectPath?), onLoggedOut(), openExternal(url) }`.
22
28
 
23
- 1. 토큰은 채널로 보내지 않는다. 세션은 서버 Set-Cookie로 다룬다(아래 "세션"). 토큰을 `postMessage`·inject 넘기지 것.
24
- 2. 추가만 허용(append-only). 타입과 옵셔널 필드만 추가한다. 기존 타입 제거·이름변경·기존 필드 필수화는 금지한다. 구버전 앱이 최신 웹과, 최신 앱이 구버전 웹과 계속 동작해야 한다.
25
- 3. tolerant reader. `reader.ts`의 `readInbound`는 예외를 던지지 않는다. ns 불일치·非JSON·미지 type·필드 깨짐은 모두 `{ type: "UNKNOWN", raw }`로 받는다. 라우터는 UNKNOWN을 소리 없이 무시한다.
26
- 4. 버전 숫자 대신 **capabilities**로 분기한다. 앱이 HELLO로 `capabilities`(문자열 배열)를 알리고 웹이 feature-detect한다. v1 = `["session.v1", "external-links.v1"]`.
27
- 5. origin 화이트리스트. inbound는 신뢰 origin(`ALLOWED_ORIGINS`)에서만 받는다. 경계에서 `safeParse`한다.
29
+ 라우팅 요약: READY→noop · REQUEST_SESSION_INSTALL→`onNeedLogin(payload?.redirectPath)` · AUTH_STATE_CHANGED→v1 noop · LOGOUT→`onLoggedOut()` `inject(buildReceiveInjection(LOGOUT_DONE))` · OPEN_EXTERNAL→`openExternal(payload.url)` · UNKNOWN→무시.
28
30
 
29
- ## 모듈 지도 (`src/bridge/`)
31
+ ## 불변식 (어기면 호환이 조용히 깨진다)
32
+
33
+ 1. **토큰은 이 채널로 보내지 않는다.** 세션은 서버 Set-Cookie로 다룬다(아래 "세션"). `postMessage`·inject로 토큰을 넘기지 않는다 — 브릿지 페이로드는 신뢰 경계 밖이라 토큰이 새면 그대로 탈취된다.
34
+ 2. **추가만 허용(additive-only).** 타입과 옵셔널 필드만 추가한다. 기존 타입 제거·이름변경·기존 필드 필수화는 금지한다. 구버전 앱이 최신 웹과, 최신 앱이 구버전 웹과 계속 동작해야 하기 때문이다(앱은 스토어 심사를 거쳐 천천히 깔린다).
35
+ 3. **tolerant reader.** `reader.ts`의 `readInbound`는 예외를 던지지 않는다. ns 불일치·非JSON·미지 type·필드 깨짐은 모두 `{ type: "UNKNOWN", raw }`로 받고, 라우터는 UNKNOWN을 소리 없이 무시한다. 그래서 신버전 웹이 보낸 모르는 메시지가 구버전 앱을 깨뜨리지 않는다.
36
+ 4. **버전 숫자 대신 capabilities로 분기한다.** 앱이 HELLO로 `capabilities`(문자열 배열)를 알리고 웹이 feature-detect한다. 토큰 목록은 `capabilities.ts`에서 읽는다.
37
+ 5. **origin 화이트리스트.** inbound는 신뢰 origin(`ALLOWED_ORIGINS`)에서만 받고, 경계에서 `safeParse`한다.
38
+
39
+ ## 모듈 지도 (`mobile/src/bridge/`)
30
40
 
31
41
  | 파일 | 역할 | 상태 |
32
42
  |------|------|------|
33
- | `contract.ts` | 봉투·`MSG`·zod 스키마·타입 — SSOT | FIXED, 웹과 바이트 단위로 동일 |
34
- | `reader.ts` | `readInbound(raw)` tolerant 파서 | FIXED |
43
+ | `contract.ts` | 봉투·`MSG`·zod 스키마·타입 | FIXED, `ssb/`와 바이트 단위 동일 |
44
+ | `reader.ts` | `readInbound(raw)` tolerant 파서 | FIXED, `ssb/`와 동일 |
35
45
  | `capabilities.ts` | `APP_CAPABILITIES`, `buildHello(...)` | 확장 |
36
46
  | `messaging.ts` | `buildReceiveInjection(msg)` → inject용 JS | 확장 |
37
47
  | `router.ts` | `handleInbound(raw, ctx)`, `helloInjection(ctx)` | 확장 |
38
48
 
39
- `BridgeContext` = `{ appVersion, platform, webOrigin, inject(js), onNeedLogin(redirectPath?), onLoggedOut(), openExternal(url) }`.
40
-
41
49
  코어 메시지: HELLO/READY · REQUEST_SESSION_INSTALL · AUTH_STATE_CHANGED · LOGOUT/LOGOUT_DONE · OPEN_EXTERNAL · UNKNOWN.
42
50
 
43
- 전방호환(forward-compat) 타입은 계약엔 있으나 v1에서 송출·처리하지 않는다. `SESSION_INSTALLED`는 세션 핸드오프 완료 통지용이며 v1에서 송출하지 않는다. `PUSH_TOKEN_UPDATED`는 푸시가 v1에 없어(`expo-notifications` 없음) 타입만 선점한 것이다. 둘 다 contract.ts에 스키마만 정의돼 있고 v1에서 빌드·송출하는 코드가 없다. 푸시를 살리려면 `expo-notifications` 추가, 토큰 획득·송출, `push.v1` capability append가 필요하다(앱 재빌드 — `eas-deploy-guide`).
44
-
45
- 라우팅 요약: READY→noop · REQUEST_SESSION_INSTALL→`onNeedLogin(payload?.redirectPath)` · AUTH_STATE_CHANGED→v1 noop 훅 · LOGOUT→`onLoggedOut()` 후 `inject(buildReceiveInjection(LOGOUT_DONE))` · OPEN_EXTERNAL→`openExternal(payload.url)` · UNKNOWN→무시.
51
+ 전방호환(forward-compat) 타입은 계약엔 스키마만 있고 v1에서 빌드·송출하는 코드가 없다. `SESSION_INSTALLED`는 세션 핸드오프 완료 통지용이고, `PUSH_TOKEN_UPDATED`는 푸시가 v1에 없어(`expo-notifications` 없음) 타입만 선점한 것이다. 푸시를 살리려면 `expo-notifications` 추가, 토큰 획득·송출, `push.v1` capability append가 필요하다(앱 재빌드 — `eas-deploy-guide`).
46
52
 
47
53
  ## 새 메시지 타입 추가하는 법
48
54
 
49
55
  1. `contract.ts`의 `MSG`에 새 상수를 append한다(제거·재정렬 금지).
50
- 2. 같은 파일에 해당 메시지 zod 스키마를 추가한다. inbound면 `InboundSchema` union에, outbound면 `OutboundSchema` union에 넣는다. 새 필드는 `.optional()`로 둔다.
51
- 3. `reader.ts`는 그대로 둔다. tolerant라 새 inbound를 자동으로 파싱하므로 손댈 일이 거의 없다.
52
- 4. inbound라면 `router.ts`의 `handleInbound` switch에 case를 추가한다. UNKNOWN 그대로 무시되니 안전하다.
53
- 5. 새 능력이면 `capabilities.ts`의 `APP_CAPABILITIES`에 capability 문자열을 추가한다. 웹이 HELLO로 감지한다.
56
+ 2. 같은 파일에 해당 zod 스키마를 추가한다. inbound면 `InboundSchema` union에, outbound면 `OutboundSchema` union에 넣고, 새 필드는 `.optional()`로 둔다.
57
+ 3. `reader.ts`는 그대로 둔다. tolerant라 새 inbound를 자동으로 파싱한다.
58
+ 4. inbound라면 `router.ts`의 `handleInbound` switch에 case를 추가한다. 빠뜨려도 UNKNOWN으로 무시되니 깨지진 않는다.
59
+ 5. 새 능력이면 `capabilities.ts`의 `APP_CAPABILITIES`에 capability 문자열을 append한다. 웹이 HELLO로 감지한다.
54
60
  6. 네이티브 변경이므로 앱을 재빌드한다(OTA 아님 — `eas-deploy-guide`).
55
- 7. 동기화한다. 같은 변경을 `ssb/`(원본)와 `web/lib/bridge/`·`mobile/src/bridge/` 세 곳에 바이트 단위로 동일하게 반영하고, 워크스페이스 루트에서 `pnpm run doctor`로 동일성을 확인한다.
61
+ 7. 같은 변경을 `ssb/`(원본)·`web/lib/bridge/`·`mobile/src/bridge/` 세 곳에 바이트 단위로 동일하게 반영하고, 루트에서 `pnpm run doctor`로 동일성을 확인한다.
56
62
 
57
63
  > 새 기능은 앱에 먼저 들어가고(HELLO capability), 웹이 나중에 감지해 쓴다. 순서가 어긋나도 웹 코드가 capability 게이트로 막혀 있어 안전하다.
58
64
 
59
65
  ## 세션 (토큰 미경유)
60
66
 
61
- > 기본값은 **웹 로그인**이다. 웹뷰 안에서 웹 자체 로그인 UI가 그대로 뜨고(쿠키·JS·storage 켜짐, `@supabase/ssr` 동작), 앱은 로그인에 아무것도 하지 않는다. 아래 네이티브 핸드오프 경로는 옵트인 fast-follow다. `requestSessionHandoff`는 v1에서 호출자가 없다(네이티브 소셜 로그인 도입 쓰는 길).
67
+ > 기본값은 **웹 로그인**이다. 웹뷰 안에서 웹 자체 로그인 UI가 그대로 뜨고(쿠키·JS·storage 켜짐, `@supabase/ssr` 동작), 앱은 로그인에 아무것도 하지 않는다. 아래 네이티브 핸드오프 경로는 옵트인 fast-follow `requestSessionHandoff`는 v1에서 호출자가 없다(네이티브 소셜 로그인을 도입할 쓰는 길).
62
68
 
63
- 핸드오프 메커니즘(2026 기준 정확, 유지): 네이티브 로그인 → 서버 POST `/auth/app-bridge`(idToken은 body에) → 1회용·단일사용·짧은 TTL nonce → 서버 Set-Cookie + 303 → 이후 웹이 세션 갱신을 단독으로 담당한다. URL `?token=`은 유출 위험이므로 금지한다.
69
+ 핸드오프 메커니즘: 네이티브 로그인 → 서버 POST `/auth/app-bridge`(idToken은 body에) → short-TTL 1회용·단일사용 nonce → 서버 Set-Cookie + 303 → 이후 웹이 세션 갱신을 단독으로 담당한다. nonce 상세 규칙은 `mobile/AGENTS.md` 불변식 2가 SSOT다.
64
70
 
65
- - 네이티브 `autoRefreshToken:false`는 필수다. 갱신기가 둘이면 Supabase refresh-token reuse-detection이 트리거되어 세션 전체가 revoke된다. 갱신기는 하나여야 한다.
71
+ - **URL `?token=`은 금지한다.** 쿼리스트링은 로그·리퍼러·히스토리에 남아 유출되므로, 세션은 body의 nonce Set-Cookie로만 넘긴다.
72
+ - **네이티브 `autoRefreshToken:false`는 필수다.** 갱신기가 둘이면 Supabase refresh-token reuse-detection이 트리거되어 세션 전체가 revoke된다. 핸드오프 뒤 갱신기는 웹 하나여야 한다.
66
73
  - 네이티브 `secureSession`의 get/set은 호출자가 없다. 웹에서 발생한 LOGOUT 시 `clearAsync`만 실행된다.
67
74
  - 로그아웃은 LOGOUT→웹 signOut→LOGOUT_DONE 양방향이다. 셸 측 배선(reload 불충분·쿠키 클리어)은 `native-app-guide`를 본다.
68
75
 
69
76
  ## 어댑터 (웹 측)
70
77
 
71
- 계약(`contract.ts`·`reader.ts`)은 이미 `web/lib/bridge/`에 설치돼 있다. 옵트인은 세션 핸드오프 레이어뿐이다 — 네이티브 소셜 로그인을 켤 때만 `docs/web-adapter/`의 POST 라우트(`route-app-bridge.ts`)·nonce·CSP를 웹 repo에 추가한다. 웹이 추가하는 의존성은 `zod` 하나뿐이다. 네이티브·webview 라이브러리는 웹에 들어가지 않는다. 웹은 `window.ReactNativeWebView` 전역으로만 앱에 닿는다. gronxb 등 라이브러리는 기각한다(네이티브-API-소유·monorepo 전제가 web=SSOT와 충돌한다). 설치하더라도 네이티브-소셜 핸드오프는 idToken 획득 코드가 없어 `requestSessionHandoff` 호출자가 여전히 없는 fast-follow 흐름이다.
78
+ 계약(`contract.ts`·`reader.ts`)은 이미 `web/lib/bridge/`에 설치돼 있다. 옵트인은 세션 핸드오프 레이어뿐이다 — 네이티브 소셜 로그인을 켤 때만 `docs/web-adapter/`의 POST 라우트(`route-app-bridge.ts`)·nonce·CSP를 웹 repo에 추가한다. 웹이 더하는 의존성은 `zod` 하나뿐이고, 네이티브·webview 라이브러리는 웹에 들어가지 않는다(웹은 `window.ReactNativeWebView` 전역으로만 앱에 닿는다). gronxb 등 라이브러리는 기각한다네이티브-API-소유·monorepo 전제가 web=SSOT와 충돌한다.
79
+
80
+ ## 함정 (조용히 깨지는 것)
81
+
82
+ - **WebView에 `limitsNavigationsToAppBoundDomains` / iOS `WKAppBoundDomains`를 설정하지 말 것.** 설정하면 onMessage·injectedJavaScript가 소리 없이 죽어 브릿지 전체가 먹통이 되는데, 에러가 안 나서 원인을 찾기 어렵다.
83
+ - **세 사본을 다 고치지 않으면** `ssb/`·`web/lib/bridge/`·`mobile/src/bridge/`가 어긋나 앱·웹이 서로 다른 계약을 들고 동작한다. 한 곳만 고치고 나머지를 잊는 게 가장 흔한 실수다 — doctor가 막아 준다.
84
+ - **기존 타입·필드를 바꾸면**(이름변경·필수화·제거) 그 순간 컴파일은 통과해도, 아직 그 변경을 모르는 구버전 앱(스토어에 깔린 것)에서 런타임에 깨진다. additive-only인 이유다.
85
+
86
+ ## 검증
87
+
88
+ 성공 = 세 사본이 동일해 `pnpm run doctor`의 브릿지 점검이 ✓이고, 추가한 메시지가 양방향으로 흐른다(앱이 HELLO로 capability를 광고하면 웹이 feature-detect로 켜고, inbound는 라우터 case로 처리된다). 조용한 실패의 신호: 보낸 메시지가 상대편에서 아무 반응 없이 사라지면 ns 불일치·미동기화·미등록 case로 UNKNOWN 처리된 것이다 — reader는 던지지 않으므로 콘솔 에러가 아니라 "무반응"으로 나타난다.
@@ -1,107 +1,65 @@
1
1
  ---
2
2
  name: eas-deploy-guide
3
- description: EAS 빌드·배포 메커니즘. EAS 빌드 프로파일(production/development)·dev build·EXPO_PUBLIC_WEB_URL·실기기 vs 시뮬레이터·테스트 채널로 빌드 송출(eas build --auto-submit)·OTA vs 네이티브 재빌드 판단·환경 모델(앱=prod 웹 래퍼) 작업 시 자동 로드됩니다. (개발자 계정·심사·콘솔 출시는 store-release-guide.)
3
+ description: EAS 빌드·배포 메커니즘과 "앱을 폰에서 보기". 앱을 폰에 설치해 보기(실폰·시뮬레이터·TestFlight·Play 내부 테스트), EAS 빌드 프로파일(production/development)·dev build·EXPO_PUBLIC_WEB_URL·테스트 채널 송출(eas build --auto-submit)·OTA vs 네이티브 재빌드 판단·환경 모델(앱=prod 웹 래퍼) 작업 시 자동 로드된다. Owner가 "앱을 폰에서 보고 싶어 / 미리보기"라고 할 때도. (개발자 계정·심사·콘솔 출시는 store-release-guide.)
4
4
  user-invocable: false
5
5
  ---
6
6
 
7
- # EAS 빌드·미리보기·배포
7
+ # EAS 빌드 · 폰에서 보기 · 배포
8
8
 
9
- EAS CLI는 글로벌로 설치하지 않는다. 항상 `npx eas-cli`를 쓰고, 명령은 앱 repo 루트에서 실행한다. 스토어 계정·인증서·심사 셋업은 `store-release-guide`가 맡는다.
9
+ EAS CLI는 글로벌로 설치하지 않는다. 항상 `npx eas-cli`를 쓰고, 앱 repo 루트에서 실행한다. 스토어 계정·인증서·심사는 `store-release-guide`가, 네이티브 셸 레시피는 `native-app-guide`가 맡는다.
10
10
 
11
- ## 환경 모델: 앱은 배포된 prod 웹 래퍼
11
+ ## 빠른 참조 (값은 여기서 읽는다)
12
+
13
+ - SDK·RN·React 버전과 브레이킹 변경: `mobile/package.json` + v56 문서(<https://docs.expo.dev/versions/v56.0.0/>). 메모리를 믿지 않는다.
14
+ - 빌드 프로파일·`EXPO_PUBLIC_*`: `mobile/eas.json`. 무료 티어 한도(월 빌드 수·동시성·빌드 시간): <https://expo.dev/pricing>.
15
+ - URL 가드·허용 origin: `mobile/src/config/env.ts`(SSOT).
12
16
 
13
- 앱에는 자체 백엔드 환경이 없다. 배포된 prod 웹 https 하나를 띄울 뿐이다. dev/prod 분기(`<repo>-dev`↔`<repo>-prod` Supabase, `VERCEL_ENV`)는 웹이 소유한다. 앱은 어느 Supabase를 쓸지 모르고, prod 웹을 띄우면 웹이 알아서 고른다. 그래서 "앱 환경"은 어느 웹 URL을 띄우는가, 그리고 어떻게 서명·배포하는가로만 갈린다. 웹 dev/prod 매트릭스는 web repo의 책임이다.
17
+ ## 환경 모델: 앱은 배포된 prod 웹 래퍼
14
18
 
15
- - "앱을 테스트한다"는 앱 빌드를 테스트 채널(TestFlight, Play 내부 테스트)로 보내 폰으로 받아 본다는 뜻이다. 어느 빌드든 같은 prod 웹을 가리킨다. 웹의 테스트 버전을 보는 것이 아니다.
16
- - 별도의 dev 웹 staging은 불필요하다. 브릿지 계약이 additive·gated이므로(`bridge-guide`) 앱 연동 웹 코드를 prod에 올려도 구버전 앱·브라우저에서 안전하다.
17
- - 링크가 앱 안에 머물지 시스템 브라우저로 나갈지의 경계(`ALLOWED_ORIGINS`)는 `native-app-guide` §링크 경계와 `src/config/env.ts`(SSOT)를 따른다.
19
+ 앱엔 자체 백엔드 환경이 없다. 배포된 prod 웹 https 하나를 띄울 뿐이고, dev/prod 분기(Supabase·`VERCEL_ENV`)웹이 소유한다. 그래서 "환경"은 *어느 URL을 띄우나*와 *어떻게 서명·배포하나*로만 갈린다. 별도 dev staging은 불필요하다 브릿지 계약이 additive·gated라(`bridge-guide`) 연동 코드를 prod에 올려도 구버전 앱·브라우저에서 안전하다.
18
20
 
19
- ## 미리보기·테스트: production 빌드 스토어 테스트 채널 (Expo Go 불가, 시뮬레이터 불필요)
21
+ ## 폰에서 보기 (= 빌드를 테스트 채널로)
20
22
 
21
- 앱은 네이티브 모듈 때문에 Expo Go로 뜨지 않는다. 비개발자 환경에는 시뮬레이터(Xcode/Android Studio)도 없다고 가정한다. 포장된 길은 production 빌드 하나를 만들어 스토어 테스트 채널로 보내 실폰에서 확인하고, 같은 빌드를 그대로 공개로 승급하는 것이다. 승급 자체는 스토어 콘솔에서 수동으로 한다(아래 §배포·릴리스). 전용 `preview`·ad-hoc 빌드는 만들지 않는다. EAS 빌드 수를 아끼고, 실제 출시 아티팩트를 그대로 검증한다.
23
+ "앱을 테스트한다 / 폰에서 본다"는 production 빌드 하나를 스토어 테스트 채널(iOS TestFlight, Android Play 내부 테스트)로 보내 실폰에 설치한다는 뜻이다. 빌드를 나중에 그대로 공개로 승급하므로 전용 preview 빌드를 따로 만들지 않는다 EAS 빌드 수를 아끼고 실제 출시 아티팩트를 그대로 검증한다.
22
24
 
23
25
  ```bash
24
- npx eas-cli build --profile production --platform all --auto-submit
26
+ npx eas-cli build --profile production --platform all --auto-submit --no-wait
25
27
  # iOS → TestFlight, Android → Play 내부 테스트. 폰에서 TestFlight 앱 / 초대 링크로 설치.
26
28
  ```
27
29
 
28
- - iOS: `eas submit`이 ASC에 업로드하고, 처리(약 10–15분) TestFlight에 올라온다. 내부 테스터(≤100, ASC ) 심사 없이 즉시 설치한다. 공개는 ASC "Submit for Review"로 같은 빌드를 제출한다.
29
- - Android: AAB가 Play 내부 테스트 트랙에 단위로 올라온다(심사 없음). 개발자 계정은 production 전에 비공개 테스트 12명·14일 연속이 필수다(`store-release-guide`).
30
- - 빠른 사이드로드(ad-hoc IPA·APK, QR/링크 직접 설치)가 필요하면 `distribution: "internal"` 프로파일을 따로 추가하는 옵션이 있다. iOS는 UDID 등록이 필요하고 그 빌드는 출시로 승급되지 않으므로 기본 경로가 아니다. day-one에는 production 빌드 설치를 단정한다.
31
-
32
- ## 실기기: prod 웹 https (localhost 함정)
33
-
34
- 앱은 배포된 prod 웹 https를 로드한다. 실기기는 Owner 컴퓨터의 `localhost`에 도달하지 못하고, http LAN은 secure-context가 아니라서 카메라·마이크·Web Crypto·클립보드·Secure 쿠키가 실기기에서 경고 없이 깨진다(Android cleartext, iOS ATS). 그러니 빌드의 `EXPO_PUBLIC_WEB_URL`은 항상 prod https로 둔다.
35
-
36
- `src/config/env.ts` 가드는 protocol/hostname만 본다. https 또는 localhost면 통과한다. 이 가드는 실기기와 시뮬레이터를 구분하지 못한다. localhost URL은 가드를 통과하고도 실기기에서는 깨진다. "실기기 = prod https"는 코드가 강제하는 불변식이 아니라 Owner와 AI가 지키는 규칙이다.
37
-
38
- ### 고급·선택: 셸을 직접 고칠 때만
39
-
40
- 네이티브 셸(`Host.tsx` 등)을 빠르게 반복할 때만 `development` 프로파일(dev client)과 `npx expo start`(Metro Fast Refresh)를 쓴다. dev build는 폰이든 시뮬레이터든 어디서나 돈다.
41
-
42
- - 시뮬레이터가 있다면(Mac=Xcode/iOS, 전 OS=Android 에뮬) 웹뷰를 `localhost`(iOS 시뮬)·`10.0.2.2`(Android 에뮬)로 가리켜 로컬 웹과 함께 고칠 수 있다. 실기기에서는 금지한다(위 secure-context 함정).
43
- - `eas.json`의 `development` 프로파일은 기본적으로 https 플레이스홀더에 cleartext/ATS·localhost가 배선되지 않은 상태로 배송된다. 로컬 dev 루프는 AI가 요청 시 세팅하는 레시피다. localhost·`10.0.2.2`와 ATS/cleartext 예외를 `development` 프로파일에만 추가한다(`production`에는 절대 넣지 않는다). 사전 구성된 프로파일이 아니다.
44
- - 대부분은 여기까지 가지 않는다. 웹 UI는 그냥 브라우저에서 고친다.
45
-
46
- ## 두 속도 (무엇이 어떻게 반영되나)
30
+ - **첫 빌드 준비는 Owner 몫이다.** Expo 계정 로그인·2FA는 Owner가 직접 한다(AI 대리 불가). 이후 빌드는 AI가 자동으로 돌린다.
31
+ - **개발자 계정이 아직 없으면**(app-launch 전) 스토어 테스트 채널을 쓴다. 그땐 EAS 내부배포로 먼저 설치한다(iOS는 UDID 등록 `store-release-guide`). 계정·채널 셋업은 `app-launch`가 맡는다.
32
+ - 빌드는 큐를 포함해 십수 ~ 20분쯤 걸린다(멈춘 아니다). 백그라운드는 항상 `--no-wait`로 두고, 진행은 Expo 대시보드 빌드 페이지에서 본다.
47
33
 
48
- | 변경 | 반영 방법 |
49
- |------|-----------|
50
- | 네이티브 셸 JS/TSX | Metro Fast Refresh — 즉시 |
51
- | 웹 변경 | 웹 재배포(or 웹뷰 새로고침) — 앱은 그대로 |
52
- | 네이티브/config(`app.config.ts`·플러그인·scheme·권한·SDK) | dev build 재빌드 |
34
+ ### 셸만 빠르게 고칠 (선택·고급)
53
35
 
54
- `EXPO_PUBLIC_*`는 빌드 시점에 인라인된다. "재빌드 없이 URL 변경"은 약속하지 않는다.
36
+ 네이티브 셸(`Host.tsx` 등)만 반복할 `development` 프로파일(dev client) + `npx expo start`(Metro Fast Refresh)를 쓴다. 시뮬레이터가 있으면(Mac=iOS, 전 OS=Android 에뮬) 웹뷰를 `localhost`(iOS 시뮬)·`10.0.2.2`(Android 에뮬)로 가리켜 로컬 웹과 함께 고칠 수 있다 — 실기기에선 금지(아래 함정). 대부분 여기까지 안 간다. 웹 UI는 그냥 브라우저에서 고친다.
55
37
 
56
- ## 배포·릴리스: OTA vs 재빌드
38
+ ## 속도: 무엇이 어떻게 반영되나
57
39
 
58
- 평소 변경(웹)은 배포로 즉시 반영된다. 앱 자체 재출시는 네이티브가 바뀔 때만 한다.
59
-
60
- | 변경 | 조치 |
40
+ | 변경 | 반영 |
61
41
  |------|------|
62
- | 웹/UI 변경 | 웹 배포 (스토어 액션 0) |
63
- | RN 순수 JS/TSX/이미지/스타일 | OTA: `npx eas-cli update` |
64
- | 네이티브 모듈·`app.config.ts`·plugins·scheme·권한·SDK 업·계약 메시지 추가 | 재빌드 + 재제출 |
65
-
66
- - `runtimeVersion.policy: "appVersion"`이므로 같은 version 빌드끼리만 OTA가 가능하다.
67
- - 재제출 시 marketing version(`app.config.ts`의 `version`) 올림이 필수다(ITMS-90186). autoIncrement는 buildNumber/versionCode만 올린다.
68
-
69
- ## eas build / submit
70
-
71
- ```bash
72
- # 스토어 빌드 + 자동 제출 (iOS=TestFlight, Android=내부 트랙까지)
73
- npx eas-cli build --profile production --platform ios \
74
- --auto-submit --non-interactive --no-wait --message "<짧은 설명>"
75
- ```
76
-
77
- - 백그라운드 진행은 항상 `--no-wait`로 둔다.
78
- - `cli.appVersionSource: "remote"`와 `autoIncrement: true`로 buildNumber/versionCode를 EAS가 자동으로 +1 한다.
79
- - `eas submit`은 공개 심사 제출까지 하지 않는다. iOS는 TestFlight, Android은 internal 트랙까지만 간다. 공개 출시(ASC "Submit for Review", Play production 승급)는 Owner가 수동으로, 명시적 confirm을 받아 한다. Android 첫 AAB는 Play Console에서 수동으로 1회 업로드해야 한다. 첫 자동 제출 실패는 정상이다([Expo: first Android submission](https://github.com/expo/fyi/blob/main/first-android-submission.md)).
80
- - 자격증명(iOS 인증서·프로비저닝·APNs, Android 키스토어)은 EAS-managed로 자동 생성된다. `eas.json`에는 public 값만 커밋하고, 시크릿은 EAS 서버에 둔다.
81
-
82
- ## EAS 무료 티어 한도
83
-
84
- iOS 15 + Android 15 빌드/월, 동시성 1, 우선순위 Low, 빌드당 45분([Expo 요금](https://expo.dev/pricing)). JS 변경은 빌드 풀을 쓰지 않지만, 초기 네이티브 셋업에는 iOS 빌드를 수 개 소모할 수 있다. "첫 빌드 10–20분 + 큐는 멈춘 게 아님"을 미리 알린다.
85
-
86
- ## SDK 56 핀 (브레이킹)
42
+ | 웹/UI(제품 기능·화면·로직) | 웹 재배포 앱은 그대로, 다음 로드에 새 웹을 띄운다 |
43
+ | 네이티브 셸의 순수 JS/TSX/이미지/스타일 | OTA: `npx eas-cli update` (즉시) |
44
+ | 네이티브 모듈·`app.config.ts`·plugins·scheme·권한·SDK·계약 메시지 추가 | 재빌드 + 재제출 |
87
45
 
88
- - RN 0.85 + React 19.2, Hermes v1 기본.
89
- - `expo/fetch` 글로벌 기본, `@expo/vector-icons` deprecated → `@react-native-vector-icons/*`.
90
- - iOS 최소 16.4, Xcode 26.4+.
91
- - 이벤트 구독 정리는 `subscription.remove()`로 한다(`removeEventListener` 제거).
46
+ - `runtimeVersion.policy: "appVersion"`이라 같은 marketing version으로 나간 빌드끼리만 OTA가 도달한다. 네이티브가 한 줄이라도 바뀌면 OTA로 못 보낸다.
47
+ - 재빌드·재제출 땐 marketing version(`app.config.ts`의 `version`) 올림이 필수다 — 안 올리면 ITMS-90186으로 거절된다(`autoIncrement`는 buildNumber/versionCode만 올린다).
48
+ - `eas submit`은 공개 심사까지 가지 않는다 — iOS TestFlight, Android은 internal 트랙까지만. 공개 출시(ASC "Submit for Review", Play production 승급)는 Owner가 콘솔에서 수동 + 명시 confirm으로 한다(`app-launch`·`store-release-guide`). Android 첫 AAB는 Play Console에 수동으로 1회 업로드해야 한다(첫 자동 제출 실패는 정상).
92
49
 
93
- ## 플랫폼 매트릭스
50
+ ## 함정 (조용히 깨지는 것)
94
51
 
95
- - iOS 시뮬은 Mac/Xcode 전용이다. Windows/Linux는 본인 iPhone과 EAS 클라우드 빌드로 한다(Mac 불필요).
96
- - Android 에뮬은 OS에서 된다( Google Play Services 포함 이미지, AOSP 아님).
97
- - 실기기 검증 항목: 로그인은 웹뷰 안의 웹 로그인 UI가 뜬다( 기능이 아님). 푸시는 v1에는 없다. 푸시를 추가했을 때만 원격 푸시를 실기기에서 검증한다(시뮬 푸시는 sandbox라 prod 검증이 아니다). 네이티브 소셜은 비활성 "coming soon" 스텁이라 v1에는 검증 대상이 없다(추가 시 Apple `getCredentialStateAsync`는 시뮬에서 항상 에러).
52
+ - **실기기 = prod https.** 실기기는 Owner 컴퓨터의 `localhost`에 닿지 못하고, http LAN은 secure-context가 아니라 카메라·마이크·Web Crypto·클립보드·Secure 쿠키가 실기기에서 경고 없이 깨진다(Android cleartext·iOS ATS). 그래서 빌드의 `EXPO_PUBLIC_WEB_URL`은 항상 prod https다.
53
+ - **URL 가드는 protocol·hostname만 본다**(`src/config/env.ts`). https거나 localhost면 통과하므로 `localhost` URL은 가드를 통과하고도 실기기에선 깨진다 — "실기기=prod https"는 코드가 강제하는 불변식이 아니라 Owner·AI가 지키는 규칙이다. localhost·`10.0.2.2`와 ATS/cleartext 예외는 `development` 프로파일에만 추가한다(`production`엔 절대 넣지 않는다).
54
+ - **Expo Go로는 뜬다**(네이티브 모듈). 비개발자 환경엔 시뮬레이터도 없다고 가정한다 포장된 길은 production 빌드를 실폰에 설치하는 것이다.
55
+ - **무료 티어 빌드 수는 제한적이다.** 초기 네이티브 셋업이 iOS 빌드를 여러 개 소모할 수 있으니 ad-hoc preview로 낭비하지 않는다(정확한 한도는 위 Expo 요금 페이지).
56
+ - **SDK 버전을 추측하지 않는다.** 작성 전 `mobile/package.json`·v56 문서를 읽는다. 행동에 영향을 주는 v56 브레이킹: 이벤트 구독 정리는 `subscription.remove()`(`removeEventListener` 제거), `@expo/vector-icons` deprecated → `@react-native-vector-icons/*`, edge-to-edge 강제 기본, `expo-status-bar`의 `backgroundColor`·`translucent` props 제거.
98
57
 
99
- ## 필수 안전장치
58
+ ## 자격증명 · 플랫폼 (짧게)
100
59
 
101
- - `src/config/env.ts`는 `EXPO_PUBLIC_WEB_URL`을 시작 시점에 명시적 throw가드한다(미설정·빈 값·URL 파싱 실패·non-https-non-localhost). protocol/hostname만 검사하므로 실기기와 시뮬을 구분하지 못한다( 참조).
102
- - `.env`는 커밋하지 않는다(`.gitignore`가 `.env`/`.env.*`를 제외하고 `.env.example`만 유지). 커밋되는 `.env.example`에는 플레이스홀더만 들어간다(실제 값은 `mobile/.env.example`·`eas.json`을 직접 본다). 인라인 소스는 클라우드 빌드는 `eas.json`의 `build.<profile>.env`, 로컬 `expo start`는 Owner가 만든 `.env`다.
103
- - 테스트와 출시 모두 production(store) 프로파일 하나를 쓴다. TestFlight(iOS), Play 내부 테스트(Android). ad-hoc 사이드로드가 필요할 때만 `distribution: internal` 프로파일을 따로 추가한다(UDID 한정, 출시로 승급되지 않음).
60
+ - 자격증명(iOS 인증서·프로비저닝·APNs, Android 키스토어)은 EAS-managed자동 생성된다. `eas.json`엔 public 값만, 시크릿은 EAS 서버에 둔다. `.env`는 커밋하지 않는다(`.env.example`만, 플레이스홀더만).
61
+ - iOS 시뮬은 Mac/Xcode 전용이다. Windows·Linux는 본인 iPhone + EAS 클라우드 빌드로 한다(Mac 불필요). Android 에뮬은 OS에서 되되 Google Play Services 포함 이미지여야 한다.
104
62
 
105
- ## 빌드 모니터링
63
+ ## 검증
106
64
 
107
- Expo 대시보드 빌드 페이지에서 진행을 확인한다. 빌드는 포함 10–20분이 걸린다(멈춘 아님). 백그라운드는 항상 `--no-wait`로 둔다.
65
+ 폰에서 보기의 성공 = 실기기에 설치한 앱이 **prod https를 띄우고 화면이 돈다**. 로그인은 웹뷰 안의 웹 로그인 UI로 뜬다( 기능이 아니다). 푸시는 v1엔 없고, 추가했을 때만 실기기에서 검증한다(시뮬 푸시는 sandbox라 prod 검증이 아니다).
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: native-app-guide
3
- description: 네이티브 셸(WebView 래퍼) 작업 시 참고. 앱 껍데기·WebView 호스트·로딩/에러/오프라인 화면·Android 뒤로가기·외부 링크/origin 경계(ALLOWED_ORIGINS)·딥링크 열기(URL 라우팅)·safe-area(노치)·파일 업로드 권한 배선·크래시 복구 작업 시 자동 로드됩니다. (앱↔웹 메시지/세션 핸드오프는 bridge-guide.)
3
+ description: 네이티브 셸(WebView 래퍼) 작업 시 참고. 앱 껍데기·WebView 호스트·로딩/에러/오프라인 화면·Android 뒤로가기·외부 링크의 시스템 브라우저 라우팅·origin 경계(ALLOWED_ORIGINS)·딥링크 열기(URL 라우팅)·safe-area(노치)·파일 업로드 권한 배선·크래시 복구 작업 시 자동 로드됩니다. (앱↔웹 메시지/세션 핸드오프는 bridge-guide.)
4
4
  user-invocable: false
5
5
  ---
6
6
 
@@ -8,31 +8,40 @@ user-invocable: false
8
8
 
9
9
  배포된 prod 웹 https를 로드하는 얇은 WebView 셸이다. 모든 화면·기능·로그인은 웹이 가지고, 셸은 WebView가 막거나 못 하는 네이티브 공백만 메운다. 셸이 진짜 네이티브로 제공하는 건 다음이다. 오프라인 화면(유일하게 분명한 네이티브 전용 화면), 첫/하드 로드와 크래시 재마운트에 한정한 로딩·에러 오버레이, Android 하드웨어 뒤로가기, 외부 링크의 시스템 브라우저 라우팅, safe-area inset 주입, 카메라/마이크/파일 업로드 권한 배선(능력 자체는 웹·라이브러리의 것). 구현은 `src/webview/`에 모이고, 링크 경계는 `src/config/env.ts`의 `ALLOWED_ORIGINS`를 SSOT로 쓴다.
10
10
 
11
- 셸은 4.2(최소기능, [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/))를 통과하도록 설계된 게 아니라 출발점이다. 4.2를 넘으려면 Owner가 진짜 네이티브 차별점을 더해야 한다. 흔한 다음 단계는 네이티브 푸시인데 v1엔 없다(contract.ts에 forward-compat `PUSH_TOKEN_UPDATED` 상수와 `push.v1` capability 자리만 있고, `expo-notifications`·토큰 취득·핸들러는 전무하다). 켜려면 AI가 expo-notifications를 추가하고, 권한·토큰을 등록하고, `PUSH_TOKEN_UPDATED`로 웹에 전달하고, cold-start는 딥링크 섹션의 가드를 배선한다. 자세한 4.2·스토어 심사는 `store-release-guide`.
11
+ 셸은 4.2(최소기능, [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/))를 통과하도록 설계된 게 아니라 출발점이다. 4.2를 넘으려면 Owner가 진짜 네이티브 차별점을 더해야 한다. 흔한 다음 단계는 네이티브 푸시인데 v1엔 없다(contract.ts에 forward-compat `PUSH_TOKEN_UPDATED` 상수와 `push.v1` capability 자리만 있고, `expo-notifications`·토큰 취득·핸들러는 전무하다). 켜려면 AI가 expo-notifications를 추가하고, 권한·토큰을 등록하고, `PUSH_TOKEN_UPDATED`로 웹에 전달하고, cold-start는 딥링크 섹션의 가드를 배선한다. 4.2·스토어 심사·개발자 계정 가격은 `store-release-guide`(SSOT).
12
+
13
+ ## 빠른 참조 (값은 여기서 읽는다)
14
+
15
+ - SDK·RN·React·라이브러리 버전과 브레이킹 변경: `mobile/package.json` + v56 문서(<https://docs.expo.dev/versions/v56.0.0/>). 학습 데이터와 다르니 메모리를 믿지 않는다.
16
+ - 설치된 네이티브 API의 실제 시그니처: `node_modules/react-native-webview/lib/*.d.ts` 등 타입 정의. docs에만 있고 코드엔 없는 이름이 흔하다(아래 함정).
17
+ - 링크 경계·허용 origin: `src/config/env.ts`의 `ALLOWED_ORIGINS`(SSOT). URL 가드 동작도 여기.
18
+ - `EXPO_PUBLIC_WEB_URL`의 정확한 값: `mobile/.env.example`(플레이스홀더만 커밋된다).
19
+ - 셸 구현 본체: `src/webview/`(`Host.tsx`·`linkBoundary.ts`·`linkBoundary.test.ts`).
12
20
 
13
21
  ## 먼저: 추측하지 말고 확인
14
22
 
15
- SDK 56 / RN 0.85 / React 19는 학습 데이터와 다르다. 네이티브 API를 쓰기 전에 설치된 타입(`node_modules/react-native-webview/lib/*.d.ts` 등)이나 v56 docs(https://docs.expo.dev/versions/v56.0.0/)읽어라.
23
+ SDK는 학습 데이터와 다르다. 네이티브 API를 쓰기 전에 설치된 타입이나 v56 docs를 읽어 시그니처를 확인한다 — 기억으로 쓴 prop·메서드가 조용히 사라졌거나 이름만 같고 동작이 다른 경우가 많다. 행동에 영향을 주는 v56 브레이킹은 함정 섹션에 모았다.
16
24
 
17
- - 이벤트 구독은 `subscription.remove()`로 정리한다(RN 0.85에서 `removeEventListener` 제거됨).
18
25
  - 컴포넌트는 `React.JSX.Element`를 반환한다. strict TS를 쓰고 `any`를 회피한다.
19
26
  - `react-native-url-polyfill/auto`가 `index.ts`에서 먼저 로드돼야 `new URL().origin`이 Hermes에서 동작한다(링크 allow-list의 전제).
20
27
  - `EXPO_PUBLIC_WEB_URL`은 빌드 시점에 인라인된다(바꾸려면 재빌드). 인라인 출처는 클라우드 빌드=`eas.json` `build.<profile>.env`, 로컬 `expo start`=Owner가 만든 `.env`다. `.env`는 커밋하지 않고(`.gitignore`), 커밋되는 `.env.example`은 플레이스홀더만 담는다(정확한 값은 `mobile/.env.example`을 직접 본다). `env.ts` 가드는 protocol/hostname만 본다. 실기기인지 시뮬레이터인지 못 구분하므로 localhost URL도 가드는 통과하나 실기기에선 실패한다. "실기기=prod https"는 코드가 강제하는 불변식이 아니라 Owner/AI 규칙이다.
21
28
 
22
29
  ## safe-area (노치 여백)
23
30
 
24
- `env(safe-area-inset-*)`는 양 플랫폼 다 못 믿는다(Android WebView 138+가 0px, iOS WKWebView 첫 페인트 점프). 그래서 책임을 분리한다: 네이티브는 inset 값을 측정하고, 웹은 레이아웃을 맡는다.
31
+ `env(safe-area-inset-*)`는 양 플랫폼 다 못 믿는다(Android WebView 0px 보고, iOS WKWebView페인트에 점프한다). 그래서 책임을 분리한다: 네이티브는 inset 값을 측정하고, 웹은 레이아웃을 맡는다.
25
32
 
26
33
  1. 네이티브가 `useSafeAreaInsets()`로 inset을 측정한다.
27
34
  2. WebView `<meta viewport>`에 `viewport-fit=cover`를 둔다.
28
35
  3. `injectedJavaScriptBeforeContentLoaded`로 그 값을 커스텀 CSS 변수(`--ssb-inset-top/right/bottom/left`, `env()`가 아님)에 주입한다.
29
36
  4. 웹이 그 변수로 패딩한다.
30
37
 
31
- `SafeAreaProvider`에 `initialMetrics={initialWindowMetrics}`를 줘 첫 프레임 flash를 방지한다. px→dp→CSS px 변환에 주의한다. edge-to-edge는 SDK 56에서 강제 기본이다(Android 15/targetSdk 35). 상태바·내비바 아이콘 스타일은 `react-native-edge-to-edge`의 `SystemBars` 한 컴포넌트로 제어한다(App.tsx `<SystemBars style="dark" />`). SDK 56에서 `expo-status-bar`의 backgroundColor/translucent props는 제거됐고, Expo가 edge-to-edge에선 SystemBars를 권한다.
38
+ `SafeAreaProvider`에 `initialMetrics={initialWindowMetrics}`를 줘 첫 프레임 flash를 방지한다. px→dp→CSS px 변환에 주의한다.
39
+
40
+ edge-to-edge가 이 SDK에선 강제 기본이다(Android의 최신 targetSdk가 화면을 시스템 바 뒤까지 그리도록 강제한다 — 이게 위 safe-area 측정이 필요한 이유다). 그래서 상태바·내비바 아이콘 스타일은 `expo-status-bar`가 아니라 `react-native-edge-to-edge`의 `SystemBars` 한 컴포넌트로 제어한다(App.tsx `<SystemBars style="dark" />`). `expo-status-bar`의 `backgroundColor`·`translucent` props는 이 SDK에서 제거됐다 — edge-to-edge에선 의미가 없어 Expo가 SystemBars를 권하기 때문이다.
32
41
 
33
42
  ## Android 뒤로가기
34
43
 
35
- `canGoBack()`이면 `webView.goBack()` + `return true`. 아니면 `return false`로 OS가 종료·예측-뒤로를 처리하게 한다. 종료확인 Alert는 선택적 레거시다(Android 13/14 예측-뒤로와 충돌). cleanup은 `BackHandler.addEventListener(...).remove()`.
44
+ `canGoBack()`이면 `webView.goBack()` + `return true`. 아니면 `return false`로 OS가 종료·예측-뒤로를 처리하게 한다. 종료확인 Alert는 선택적 레거시다(Android 13/14 예측-뒤로와 충돌). cleanup은 `BackHandler.addEventListener(...).remove()` — 이 SDK에서 `removeEventListener`가 제거됐기 때문이다(이벤트 정리는 항상 구독 객체의 `.remove()`).
36
45
 
37
46
  ## 링크 경계 (외부 링크)
38
47
 
@@ -79,7 +88,7 @@ WebView 프로세스가 죽으면 `key`를 증가시켜 통째 재마운트한
79
88
 
80
89
  네이티브 로딩·에러 오버레이는 첫/하드 WebView 로드(빈 WebView)와 크래시 재마운트에만 덮는다. 인앱 Next.js SPA(클라이언트) 내비게이션은 네이티브 WebView에 보이지 않으므로, 페이지 전환 로딩과 인페이지/SPA 에러는 웹 자체 UI가 처리한다(셸이 중복 제공하지 않는다).
81
90
 
82
- - 로딩: `startInLoadingState`/`renderLoading`을 쓰지 않는다(그 이름은 docs에만 있고 코드엔 없다). 직접 관리하는 `loading` state를 첫 `onLoadEnd`에서 해제한다(`loading` i18n).
91
+ - 로딩: `startInLoadingState`/`renderLoading`을 쓰지 않는다(그 이름은 docs에만 있고 코드엔 없다 — 추측하지 말고 확인하라는 이유다). 직접 관리하는 `loading` state를 첫 `onLoadEnd`에서 해제한다(`loading` i18n).
83
92
  - 에러: `onError`(네트워크/전송) + `onHttpError`(메인 프레임 에러 상태)만으로 에러 화면을 띄운다(`error.*` i18n + 재시도 버튼). 재시도 = `key` 증가 통째 재마운트(크래시 복구와 동일).
84
93
  - 사용자 문자열은 전부 `src/i18n.ts` `t()`를 경유한다.
85
94
 
@@ -91,6 +100,10 @@ WebView 프로세스가 죽으면 `key`를 증가시켜 통째 재마운트한
91
100
  - Android: `CAMERA` 권한과 `<queries>` IMAGE_CAPTURE 인텐트 가시성(app.config.ts `withImageCaptureQuery` 플러그인). Android 11+ 패키지 가시성 때문에 둘은 별개 요구사항이다. 권한=카메라 사용, queries=카메라 앱 조회(`resolveActivity`). queries가 누락되면 갤러리·파일 선택은 되나 `<input type=file capture>`의 '카메라 촬영' 옵션이 안 뜬다(크래시는 아니다).
92
101
  - 키보드 focus: `app.config.ts` `android.softwareKeyboardLayoutMode: "resize"` · iOS는 WebView prop `keyboardDisplayRequiresUserAction={false}`(Host.tsx).
93
102
 
103
+ ## 아이콘 (벡터 아이콘)
104
+
105
+ 아이콘 라이브러리가 바뀌었다. `@expo/vector-icons`는 이 SDK에서 deprecated이므로 `@react-native-vector-icons/*`(scoped 패키지)를 쓴다. 기억으로 `@expo/vector-icons`를 import하면 타입은 통과해도 deprecated 경로라 점진적으로 깨지므로, 추가 전 `mobile/package.json`에서 실제 설치된 아이콘 패키지를 확인한다.
106
+
94
107
  ## 로그인 · 소셜(FAST-FOLLOW)
95
108
 
96
109
  로그인은 웹이 담당한다. 기본 상태에서 앱은 로그인에 아무것도 하지 않는다. 웹 로그인 화면이 웹뷰 안에 그대로 뜨고 쿠키·세션도 웹의 `@supabase/ssr`가 처리한다. 네이티브 로그인 모달(`src/auth/LoginScreen.tsx`)은 웹이 `REQUEST_SESSION_INSTALL`을 보낼 때만 뜨는 옵트인 스캐폴드이며, 유일하게 동작하는 버튼('이메일로 계속')은 모달을 닫아 웹 로그인으로 되돌릴 뿐이다(인증·재로드·네비게이션 없음). 소셜 원클릭(Google/Apple)은 disabled 'coming soon' 스텁이다(fast-follow).
@@ -99,4 +112,25 @@ WebView 프로세스가 죽으면 `key`를 증가시켜 통째 재마운트한
99
112
 
100
113
  ## 세션 핸드오프 (요약 — 상세는 bridge-guide)
101
114
 
102
- 서버 Set-Cookie + 303(네이티브 쿠키 주입이 아니다). URL `?token=`은 금지하고 1회용 nonce 교환을 쓴다. 로그아웃은 reload로 불충분하므로 `key` 증가 + iOS WebKit 쿠키/데이터스토어 클리어를 한다. ready 핸드셰이크는 웹 ack 단독으로 두지 않는다(`onMessage`는 fire-and-forget이라 iOS에서 드롭된다). 네이티브 타임아웃+재시도 fallback으로 흰 화면 hang을 방지한다.
115
+ 서버 Set-Cookie + 303(네이티브 쿠키 주입이 아니다). URL `?token=`은 금지하고 1회용 nonce 교환을 쓴다. 로그아웃은 reload로 불충분하므로 `key` 증가 + iOS WebKit 쿠키/데이터스토어 클리어를 한다. ready 핸드셰이크는 웹 ack 단독으로 두지 않는다(`onMessage`는 fire-and-forget이라 iOS에서 드롭된다). 네이티브 타임아웃+재시도 fallback으로 흰 화면 hang을 방지한다.
116
+
117
+ ## 함정 (조용히 깨지는 것)
118
+
119
+ 대부분 타입은 통과하고 런타임에만 조용히 깨지는 것들이다 — 그래서 기억이 아니라 확인이 기본값이다.
120
+
121
+ - **이벤트 정리에 `removeEventListener`를 쓰면 안 정리된다.** 이 SDK에서 그 메서드는 제거됐다. `BackHandler`·`NetInfo`·`Linking`·딥링크 리스너 전부 구독 객체의 `.remove()`로 푼다. 옛 패턴으로 쓰면 에러 없이 리스너가 누수돼 재마운트마다 중복 핸들러가 쌓인다.
122
+ - **`expo-status-bar`의 `backgroundColor`·`translucent` props는 제거됐다.** edge-to-edge 강제 기본에선 의미가 없어졌다. 상태바·내비바는 `react-native-edge-to-edge`의 `SystemBars`로만 제어한다. 옛 props는 무시되거나 타입 에러를 낸다.
123
+ - **아이콘을 `@expo/vector-icons`에서 import하면 deprecated 경로다.** `@react-native-vector-icons/*`로 옮긴다(빠른 참조의 설치 패키지 확인).
124
+ - **URL 가드는 protocol·hostname만 본다.** https거나 localhost면 통과하므로 localhost URL도 가드를 통과하고 실기기에선 깨진다. "실기기=prod https"는 코드가 강제하지 못하는, Owner·AI가 지키는 규칙이다.
125
+ - **링크 경계에서 `startsWith`로 origin을 비교하면 서브도메인 prefix 위장이 뚫린다.** `new URL().origin` 등가 비교만 쓴다. 동일 origin을 external로 보내면 Android #2819 유령 리로드가 난다.
126
+ - **`onOpenWindow` 누락 → `_blank` 링크가 백지로 뜬다.** `setSupportMultipleWindows`는 기본 TRUE라 prop을 안 건드려야 `onOpenWindow`가 동작한다.
127
+ - **`onRenderProcessGone`에서 `return false` → 앱이 WebView와 함께 죽는다.** 크래시 복구는 `reload()`가 아니라 `key` 증가 재마운트여야 Android가 인스턴스를 폐기·재생성한다.
128
+ - **라이브 연결 끊김을 `onError`로 잡으려 하면 안 잡힌다**(로드 실패만 잡는다). 끊김 감지는 `netinfo` 구독이 필수다.
129
+ - **iOS usage string 누락 시 Apple이 런타임에 앱을 종료시킨다**(크래시가 아니라 강제 종료). Android queries 누락은 더 조용하다 — '카메라 촬영' 옵션만 안 뜨고 갤러리는 멀쩡하다.
130
+ - **`startInLoadingState`/`renderLoading`은 코드에 없다**(docs에만 있는 이름). 직접 관리하는 `loading` state를 쓴다.
131
+
132
+ ## 검증
133
+
134
+ 성공은 실기기에 설치한 앱이 prod 웹 https를 띄우고, 네이티브 공백이 메워질 때다: 비행기 모드에서 오프라인 화면이 뜨고(연결 복구 시 자동 회복), 외부 도메인 링크가 시스템 브라우저로 빠지고 허용 origin은 in-app으로 남고, 노치 기기에서 상단·하단 콘텐츠가 시스템 바에 가리지 않고, Android 하드웨어 뒤로가기가 웹 히스토리를 따라간다.
135
+
136
+ 조용한 실패의 신호: 재마운트를 반복할수록 핸들러가 중복돼 동작이 누적되면(이벤트 정리 미흡), `_blank` 링크가 백지로 뜨면(`onOpenWindow` 누락), 실기기에서 카메라·쿠키·클립보드가 경고 없이 안 되면(localhost URL이 가드를 통과). 링크 경계는 `linkBoundary.test.ts`(vitest)로, 전체 빌드 건전성은 `mobile/`에서 `pnpm run typecheck`로 가른다.
@@ -6,7 +6,7 @@ description: Adaptive interview to probe an idea, plan, feature, data model, or
6
6
  # /probe
7
7
 
8
8
  Owner와 공유된 이해에 도달할 때까지 집요하게 조사하라.
9
- 회색지대를 없애야한다.
9
+ 회색지대를 없애야 한다.
10
10
 
11
11
  결정들을 하나씩 짚어 내려가며, 서로 얽힌 의존 관계를 풀어라.
12
12
 
@@ -14,4 +14,16 @@ Owner의 인지부하를 줄이기 위해,
14
14
  AskUserQuestion을 사용하여 한 번에 하나씩 질문하라.
15
15
  각 질문마다 네가 추천하는 답안도 표시하라.
16
16
 
17
- 만약 어떤 질문이 현재 디렉토리를 탐색해서 답할 수 있다면, 대신 디렉토리를 탐색하라.
17
+ 만약 어떤 질문이 현재 디렉토리를 탐색해서 답할 수 있다면, Owner에게 묻지 말고 디렉토리를 탐색하라.
18
+
19
+ ## 끝내는 법
20
+
21
+ 모든 회색지대가 탐색되면, 합의된 결정을 짧은 기록(plan)으로 남기거나 남길지 묻는다.
22
+ 이 기록이 이후 build 단계의 입력이 된다 — Owner가 다시 설명하지 않아도 되고, 무엇이 왜 그렇게 정해졌는지가 다음 작업으로 이어진다.
23
+ 대화로 끝나 버리면 합의가 휘발해 같은 결정을 다시 캐물어야 한다.
24
+
25
+ ## 검증
26
+
27
+ 성공은 결정들이 Owner의 머릿속이 아니라 *공유된 형태*로 닫힐 때다.
28
+ 남은 회색지대 없이 합의에 도달했고, 그 합의가 다음 단계가 집어 들 수 있는 기록으로 남았으면(또는 Owner가 남기지 않기로 명시했으면) 완료다.
29
+ 열린 가정이 그대로 남아 있거나 합의가 대화에만 있고 어디에도 적히지 않았다면 아직 끝나지 않은 것이다.
@@ -5,15 +5,21 @@ description: Make working pages in the dev server so Owner can pick from design
5
5
 
6
6
  # /sketch
7
7
 
8
- sketch 페이지 경로에 Owner의 의도에 맞춰 스케치한다.
9
- Owner는 dev 서버 브라우저를 열고, 결과물을 확인하여 피드백·확정할 수 있어야한다.
8
+ `app/sketch/<feature>/`에 Owner의 의도에 맞춰 동작하는 페이지를 스케치한다.
9
+ Owner는 dev 서버 브라우저로 결과물을 보고 피드백·확정할 수 있어야 한다.
10
10
 
11
- 디자인 시안은 서로 다른 디자인 안들을 펼쳐, Owner가 어느 룩으로 갈지 고를 수 있게 한다.
12
- 시나리오는 데이터·상태 케이스(Best / Common / Worst 등)를 충분히 표현하여 Owner의 의사결정에 도움을 준다.
11
+ 디자인 시안은 서로 다른 안을 나란히 펼쳐, Owner가 어느 룩으로 갈지 고르게 한다.
12
+ 시나리오는 데이터·상태 케이스(Best / Common / Worst 등)를 충분히 표현해 의사결정을 돕는다.
13
13
 
14
- 프로젝트의 디자인 시스템을 탐색하여 따른다.
14
+ 프로젝트의 디자인 시스템(shadcn + 디자인 컨벤션)을 탐색해 따른다.
15
15
  업데이트가 필요한 source가 보이면 Owner 확인 후 반영한다.
16
16
 
17
- 확정 후에는 선택된 안만 sketch 경로에 남기고 나머지는 정리한다.
18
- 남은 안은 다음 디자인 작업의 레퍼런스로 누적된다.
19
- 정리 방식이 모호하면 Owner에게 묻는다.
17
+ ## 정리 (확정 후)
18
+
19
+ `app/sketch/`는 *현재 검토 중인 시안*만 사는 임시 공간이다. 확정되면 채택안만 이 경로에 남기고 나머지 변형은 비워, 다음 스케치가 죽은 안 위에서 시작하지 않게 한다.
20
+
21
+ 비운 안은 버리지 말고 디자인 레퍼런스로 옮겨 둔다 — 탈락한 룩·상태 케이스는 다음 디자인 작업에서 다시 꺼내 보는 자산이다. 즉 정리는 *삭제*가 아니라 *라이브 경로에서 빼서 보관*이다. 어디에·어떻게 보관할지 모호하면 Owner에게 묻는다.
22
+
23
+ ## 검증
24
+
25
+ `app/sketch/<feature>/`에 채택안 하나만 남아 dev 서버에서 돌고, 탈락 안들은 사라진 게 아니라 레퍼런스로 옮겨져 있다.