Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/handoffs/2026-07-22-918-error-surface-handoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Handoff — #918 Error 표면 준수 (2026-07-22)

> Status: active
> Source: Issue #918 (map) / branch `docs/918-error-grilling` (worktree `.claude/worktrees/918-error-grill`, **unpushed**)
> Last verified: `gh issue view 918/921/922` + 발행 확인 OK. 코드 변경 0 — 빌드/테스트 해당 없음.

## Pick Up Here

**두 갈래 중 하나를 고른다.**

(A) **결정문 랜딩** — `docs/918-error-grilling` 브랜치를 push하고 PR(base=`dev`, docs-only)을 연다. 이게 미완인 이유는 세션 종료 시 사용자 승인을 못 받아서다(외부 발행이라 임의 진행 안 함). 티켓 6개가 전부 `docs/superpowers/specs/2026-07-22-error-surface-compliance-decisions.md`를 참조하는데 그 파일이 dev에 없어 **경로 참조가 dangling** — 다만 결정 본문(D1~D9·R1~R5)은 #921/#922 코멘트에 전부 풀어 써 놨으므로 구현자가 막히진 않는다. 병렬 디스패치 플랜 §4가 "S5(docs)는 접점 0 → 언제든 독립 랜딩"이라 했으니 충돌 없음.

(B) **구현 착수** — `/implement` 로 **#1055부터**. 순서는 `#1055 → #1056 → #1057`(의존), `#1058`·`#1059`·`#1060`은 서로 독립이라 아무 때나 병렬. **#1056을 #1057보다 먼저** 두는 게 중요하다 — R1(@modal parallel route 회귀)이 거기서 처음 드러나고, 해소책이 필요했다면 #1057 범위가 달라진다. 각 티켓은 self-contained(AC에 verify 스텝 포함).

## Current State

- **#921·#922 CLOSED** — grilling 분기 전부 해소, 결정 코멘트가 이슈에 서면화됨.
- **#918 맵 갱신** — 본문에 감사 정정·Next 제약·`Decisions so far`·`Risks`·`Out of scope` 반영 + 티켓 목록 코멘트.
- **결정문 커밋 `b1f14cf9`** (이 브랜치, **미push**): `docs/superpowers/specs/2026-07-22-error-surface-compliance-decisions.md`.
- **6 슬라이스 발행** (전부 `ready-for-agent`, 코드 착수 0):

| # | 슬라이스 | Blocked by | 리스크 |
|---|---|---|---|
| #1055 | [prefactor] `shared/ErrorState`·`EmptyState` 보강 (action prop·아이콘) | 없음 | R4 |
| #1056 | (shell) 에러 경계 — 500·엔티티 404가 AppShell 유지 | #1055 | **R1**·R2 |
| #1057 | admin 에러 경계 — AdminAppShell 유지 | #1055 (권장 #1056 후) | R5 |
| #1058 | 루트 404 토큰 마이그레이션 (bespoke 유지) | 없음 | R3 |
| #1059 | 루트 `loading.tsx` 오적용 수정 + 토큰 | 없음 | — |
| #1060 | lazy/dynamic 위젯 클라 ErrorBoundary | 없음 | — |

## Remaining Work

- [ ] `docs/918-error-grilling` push + docs PR (base=dev) — **사용자 승인 대기**
- [ ] `/implement` #1055 → #1056 → #1057
- [ ] `/implement` #1058 · #1059 · #1060 (병렬 가능)
- [ ] 이 handoff을 `archive/`로 `git mv` (다음 세션이 완전히 이어받으면)

## Gotchas

- **R1 = 최대 지뢰.** 루트 `not-found.tsx` 주석에 "@modal parallel route + auto not-found → `React.Children.only`" 회귀 이력이 있고 `@modal` 슬롯이 `app/@modal`·`app/[locale]/@modal` **양쪽**에 존재한다. nested 경계 추가가 이걸 재발시킬 수 있다 — dev·프로덕션 빌드 **양쪽**에서 재현 확인 필수. 깨지면 슬롯 `default.tsx` 보강.
- **#1059의 전제는 미검증이다.** "루트 `loading.tsx`(HomeLoading)가 전 라우트 전환에 뜬다"는 코드 구조에서 추론한 것이고 브라우저 재현은 안 했다. 정적/ISR 프리렌더(#942)에선 Suspense 폴백이 기대만큼 안 뜰 수 있다. AC에 재현 검증을 걸어놨으니 **재현이 안 되면 폐기가 아니라** "root loading이 홈 전용인 것 자체를 중립화"로 리프레임할 것.
- **오타 URL 404가 셸 밖으로 가는 건 의도된 동작이다**(D2). 회귀로 오해하고 catch-all shim을 넣지 말 것 — 신규 라우트를 조용히 삼키고, `/asdf`(locale prefix 없음)는 어차피 못 잡아 통일이 구조적으로 불가능해서 기각한 것.
- **맵 #918 Notes의 원본 감사에 오류 2건이 있었다** (본문에 정정 반영됨): shared 컴포넌트는 "미사용"이 아니라 **소비자 0 dead export**, "헐벗은 블록"은 **shell 경로에서만** 참(`/ko/asdf`는 레거시 nav가 붙어 404가 두 얼굴). 옛 캐시된 요약을 그대로 믿지 말 것.
- **워크트리 규율**: 이 워크트리(`918-error-grill`)에서만 작업. 세션 중 `.git/worktrees/…/index.lock` 충돌이 한 번 발생했다(다른 세션의 일시적 git 작업) — 파괴적 git 전에 `git worktree list` + 라이브 세션 확인.
- `docs/handoffs/` track/untrack 정책(#959, 커밋 `bd284c12`)이 미결이다. 현재 `origin/dev` 기준으론 tracked라 그대로 커밋했다.

## Verification

- 코드 변경 0 → 빌드/테스트 **해당 없음**(not run).
- 감사 사실은 전부 코드로 재확인: 라우트 트리·`ConditionalNav`/`AppShell`/`AdminLayout` 구현·`notFound()` 호출처 5곳·`shared/*` import 0건·`--primary` 토큰 값·`dynamic()` 6곳·`react-error-boundary` 미설치.
- 발행 확인: #921/#922 CLOSED, #918 본문+코멘트 반영, #1055~#1060 생성.

## Links

- PRs / issues: #918(map) · #921·#922(CLOSED) · #1055~#1060(ready-for-agent)
- plans / specs: `docs/superpowers/specs/2026-07-22-error-surface-compliance-decisions.md` (커밋 `b1f14cf9`, **미push**)
- 근거 문서: `docs/design-system/redesign/shell.md`(R4/R6) · `docs/design-system/patterns.md` §3.2/3.3 · `docs/design-system/colors.md`
- 상위 세션: `docs/handoffs/2026-07-22-kiyori-parallel-dispatch-plan.md` §1 S5 · `docs/handoffs/2026-07-22-kiyori-total-status-audit-handoff.md`
1 change: 1 addition & 0 deletions docs/handoffs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ status vocab: `active`(이어가는 중) · `blocked`(외부 의존 대기) · `
| -------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| **ready-for-review** | 작업 감사 → fixes + VTON | [2026-07-09-work-audit-fixes-vton](2026-07-09-work-audit-fixes-vton-handoff.md) (#807 로컬QA통과·#808 코드검증·#620 dev머지 — 리뷰/머지 대기) |
| **ready-for-review** | kiyori 프론트 지도 #789 | [2026-07-09-wayfinder-789-decisions-complete](2026-07-09-wayfinder-789-decisions-complete-handoff.md) (결정 9/9 해소 — `/to-spec` 준비) |
| **active** | Error 표면 준수 #918 | [2026-07-22-918-error-surface](2026-07-22-918-error-surface-handoff.md) (결정 D1~D9 확정·#921/#922 CLOSED → 6슬라이스 #1055~#1060 발행; 남은=결정문 push/PR + `/implement`) |
| **active** | 이미지/웹 최적화 #810 | [2026-07-09-image-web-opt](2026-07-09-image-web-opt-handoff.md) (이미지 슬라이스 #813/#814/#856+#863 dev 머지; 남은=#812/#815/#817/#818 + #811→#819 · 라이브 QA #820) |
| **active** | UI/UX 조정 (프론트) | [2026-07-09-ui-surface-adjustment](2026-07-09-ui-surface-adjustment-handoff.md) (홈·익스플로어·라이브러리·프로필 i18n+조정; #766/#770 선행 머지) |
| **active** | AI 하네스 표준화 | [2026-06-25-ai-harness-standardization](2026-06-25-ai-harness-standardization-handoff.md) |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Error 표면 준수 — 결정문 (#918 / #921 / #922)

> Created: 2026-07-22 | Grilling 세션 산출 (S5, 워크트리 `918-error-grill`)
> Parent map: #918 🗺️ Error page 준수 — 세그먼트 경계·셸 유지·토큰/shared 조합
> Grilling tickets: #921(경계 정책) · #922(스타일 준수)
> 상태: 결정 확정 → `/to-tickets` 대상

---

## 0. 감사 정정 (charting 시점 Notes 대비)

부모 맵 #918 Notes 중 두 항목을 코드로 재확인해 정정한다.

| 맵 기재 | 실제 (2026-07-22 확인) |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| "shared/ErrorState 미사용" | **소비자 0의 dead export**. `lib/components/shared/index.ts`가 export하지만 import하는 파일 0건. admin=`AdminEmptyState`, explore=`CatalogEmptyState`, profile=자체 `EmptyState` — 3계열 별도 구현이 병존. |
| "404 → nav 없는 헐벗은 블록" | **shell 경로에서만 참**. `/ko/asdf`처럼 매칭 자체가 없는 URL은 `isShellRoute=false`라 레거시 다크 nav(SmartNav+MobileHeader)가 붙는다 → 현재 404는 **경로에 따라 두 얼굴**. |

추가로 드러난 사실:

- **`--primary`가 이미 라임 브랜드 액센트**다 (`globals.css:56` `oklch(0.9122 0.1686 102.99)` ≈ `#eafd67`, 라이트 대비용 `--primary-strong` = `#F7E64A` 계열). `not-found.tsx`의 `#f7e64a` 하드코딩은 **토큰 값을 손으로 베낀 것**이다.
- `docs/design-system/redesign/shell.md` **R4가 `#eafd67` 하드코딩을 회귀 리스크로 이미 명시**. 즉 토큰화는 신규 정책이 아니라 기존 표준의 미적용분 회수.
- **`react-error-boundary` 미설치**, 자체 `componentDidCatch`/`getDerivedStateFromError` 구현도 **0건**.
- 하드코딩 hex(`#050505`/`#eafd67`/`#f7e64a`)는 **92곳 / 20+ 파일**에 퍼져 있다 — 에러 표면만의 문제가 아니다(스코프 경계 필요).
- `app/loading.tsx`는 **루트 loading**이라 앱 전 라우트에 적용되는데 내용은 홈 전용 스켈레톤(`HomeLoading`, 히어로+매거진 그리드)이다. 라우트 전환마다 홈 스켈레톤이 뜬다.

---

## 1. Next 16.2.1이 강제하는 제약 (결정을 가르는 지점)

이 4개가 "셸 유지"를 한 덩어리로 결정할 수 없게 만든다.

1. `app/[locale]/(shell)/error.tsx` → **AppShell 안**에서 렌더된다 (같은 세그먼트의 layout 내부에 경계가 놓임).
2. `app/[locale]/(shell)/not-found.tsx` → 페이지가 **명시적으로 `notFound()`를 호출한 경우만** 잡는다.
3. **매칭되는 라우트가 없는 URL의 404는 nested not-found를 건너뛰고 무조건 루트 `app/not-found.tsx`로 간다.** 셸 안으로 가져오려면 `app/[locale]/(shell)/[...rest]/page.tsx` catch-all shim이 **별도로** 필요하다.
4. locale prefix 없는 오타(`/asdf`)는 `[locale]` 트리 밖이라 catch-all shim으로도 못 잡는다 → **완전 통일은 구조적으로 불가**.

엔티티 라우트도 한 트리에 있지 않다:

- `(shell)` 안: `posts/[id]` · `profile/[userId]` · `items/[id]` · `artists/[id]` · `brands/[id]`
- `(shell)` 밖: **`app/cody/looks/[slug]`** (locale·shell 둘 다 밖, AppShell 자체가 없음)
- **`decoded/[id]`는 실재하지 않는다** — `(shell)/decoded/page.tsx` index 하나뿐 (#921 본문의 오기)

---

## 2. 결정 — #921 경계 정책

### D1. shell 라우트의 500은 **in-shell 유지**

`app/[locale]/(shell)/error.tsx`를 추가해 AppShell(사이드바 + 모바일 상/하단바) 안에서 에러 블록을 렌더한다. 사용자가 에러 화면에서 다른 데로 이동할 수 있어야 한다.

경계는 Next가 자동으로 3단이 된다 — 추가 분기 로직 없음:

| 경계 | 잡는 것 | chrome |
| ---------------------- | ------------------- | --------------------- |
| `(shell)/error.tsx` | shell 페이지 throw | AppShell 유지 |
| `app/error.tsx` | shell layout throw | chromeless (현행 유지) |
| `app/global-error.tsx` | root layout throw | 인라인 스타일 (불가피) |

### D2. 404는 **엔티티 없음만 in-shell** — 오타 URL은 루트 브랜드 404 유지

`app/[locale]/(shell)/not-found.tsx` **1개만** 추가한다. catch-all shim(`[...rest]`)은 **채택하지 않는다**.

| URL | 착지 | chrome |
| ---------------- | ------------------- | ----------------- |
| `/ko/posts/9999` | `(shell)/not-found` | AppShell 유지 ✅ |
| `/ko/asdf` | `app/not-found` | 브랜드 풀페이지 |
| `/asdf` | `app/not-found` | 레거시 다크 nav |

근거: 제품적으로 빈도가 높고 복구 가치가 큰 쪽(삭제·비공개된 룩/유저/아이템)만 건드린다. catch-all shim은 향후 신규 라우트를 조용히 삼킬 위험이 있고, 어차피 `/asdf`는 못 잡아 완전 통일이 안 된다 → 비용 대비 이득이 없다.

### D3. `(shell)/not-found.tsx`는 **공용 1개**로 시작

문구는 일반형("찾을 수 없는 페이지예요 / 삭제되었거나 비공개로 전환됐을 수 있어요") + 탐색 유도 CTA. 나중에 특정 엔티티만 자기 `not-found.tsx`로 오버라이드하면 되므로 확장 경로는 열려 있다.

### D4. admin 트리에도 **같은 패턴 적용**

`app/admin/error.tsx` + `app/admin/not-found.tsx`를 추가해 `AdminAppShell` 사이드바를 유지한다. 현재 admin은 루트로 튀는 데다 `ConditionalNav`가 `/admin`에서 자기억제해 **사이드바도 nav도 없는 완전 백지**가 된다. 패턴 재사용이라 비용이 작고, admin은 `shared/ErrorState` 수렴의 자연스러운 첫 지렛대다. i18n 불필요(현 admin은 영문 하드코딩 관행).

### D5. shell 밖 소비자 라우트(`app/cody/*` · `search` · `about` · `login` 등)는 **이번 스코프 제외**

이 라우트들은 `isShellRoute=false`라 루트 404가 떠도 레거시 SmartNav+MobileHeader가 이미 붙는다 → "헐벗은 블록" 문제가 여기엔 없다. 게다가 `cody/*`는 #919/#920에서 GPT Image 전환으로 재편 중이라 지금 경계를 박으면 재작업이 된다.

**결과 — #921이 만드는 파일 4개**: `(shell)/error.tsx` · `(shell)/not-found.tsx` · `admin/error.tsx` · `admin/not-found.tsx`

---

## 3. 결정 — #922 스타일 준수

### D6. `not-found.tsx`·`loading.tsx`는 **시맨틱 토큰으로 마이그레이션** (브랜드 아이덴티티는 유지)

`#050505`/`#f7e64a`/`#eafd67` → `bg-background`/`text-foreground`/`text-primary`/`bg-primary`. **브랜드를 버리는 게 아니다** — `--primary`가 이미 그 라임이고, 라이트 테마에서 텍스트 대비가 필요한 자리엔 `--primary-strong`을 쓴다. 워드마크·2px 룰·트래킹 등 레이아웃 아이덴티티는 그대로 둔다.

근거: 리디자인 기본 테마가 라이트인데 루트 404/로딩만 다크로 고정돼 테마 전환이 깨진다. `shell.md` R4가 이미 같은 하드코딩을 회귀로 규정했다.

**스코프 경계**: 이 두 파일 + 새로 만드는 경계 4개까지만. 나머지 하드코딩 hex 90여 곳(레거시 다크 표면 전반)은 **이번 스코프 밖** — 별도 티켓으로 분리한다.

### D7. 새 경계는 **`shared/ErrorState`·`EmptyState` 조합**, 루트 404는 **bespoke 유지**

- `(shell)/error.tsx` · `(shell)/not-found.tsx` · `admin/error.tsx` · `admin/not-found.tsx` → shared 조합. dead export였던 두 컴포넌트가 **첫 소비자를 얻는다**.
- `app/not-found.tsx`(루트) → **bespoke 브랜드 풀페이지 유지**. 외부 유입·오타 URL이 착지하는 마케팅성 표면이라 인라인 상태 블록과 성격이 다르다. 단 D6의 토큰화는 적용.
- shared 보강 필요분: `ErrorState`에 `action` prop(현재 `onRetry`만 — Link CTA를 못 넣음), 아이콘 오버라이드. `patterns.md` 3.2가 상정한 `title/message/action` 시그니처에 맞춘다.
- **기능별 중복 empty state 수렴(AdminEmptyState / CatalogEmptyState / profile EmptyState 3계열)은 이번 스코프 밖** — 별도 티켓.

### D8. 클라이언트 `ErrorBoundary`는 **도입하되 대상 최소**

`react-error-boundary` 도입. Next의 `error.tsx`는 **서버/렌더 에러만** 잡고 lazy 청크 로드 실패·위젯 단위 클라 에러는 못 잡는다 — 실제 위험은 배포 중 dynamic import 청크 404다(`SmartNav`·`LazyVtonModal`·`LazyTasteOnboardingPrompt` 등 dynamic import가 실사용 중).

적용 대상은 **lazy/dynamic 위젯 한정**으로 좁힌다. 전역 래핑은 하지 않는다(라우트 error.tsx와 이중 처리 → 진단 혼선).

### D9. 루트 `app/loading.tsx` 오적용 — **이번 스코프에 포함**

`HomeLoading`이 루트에 있어 **모든 라우트 전환**에 홈 히어로 스켈레톤이 뜬다. 홈 전용 스켈레톤을 라우트에 맞게 재배치하거나 루트 loading을 중립 스켈레톤으로 교체한다. D6의 토큰화와 같은 파일을 건드리므로 함께 처리한다.

---

## 4. 리스크 (구현 티켓의 명시 verify 항목)

| # | 리스크 | verify |
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| R1 | **parallel route 지뢰** — 루트 `not-found.tsx` 주석에 "@modal + auto not-found → `React.Children.only`" 이력. `@modal` 슬롯이 `app/@modal`·`app/[locale]/@modal` 양쪽 존재. nested 경계 추가가 재발시킬 수 있음 | `/ko/posts/9999`·shell throw를 실제 dev/build에서 재현. 깨지면 슬롯 `default.tsx` 보강 |
| R2 | `(shell)/error.tsx`는 `"use client"`인데 AppShell 하위 서버 컴포넌트 트리와 경계가 맞물림 — 정적 렌더(#942 `setRequestLocale` 규율) 회귀 가능 | `bun run build`로 `(shell)` 세그먼트가 dynamic으로 떨어지지 않는지 확인 |
| R3 | 토큰 마이그레이션이 라이트 테마에서 라임-on-화이트 **대비 미달**(WCAG AA) 낼 수 있음 | `--primary-strong` 적용 후 대비비 측정 (`colors.md` 기준) |
| R4 | `shared/ErrorState` 시그니처 변경이 향후 소비자에 영향 — 지금은 소비자 0이라 안전하나 admin 수렴 티켓과 순서 충돌 가능 | prop 추가는 optional-only(파괴적 변경 금지) |
| R5 | admin `error.tsx`가 `AdminLayout`의 auth try/catch와 중첩 — 인증 실패가 에러 화면으로 새 나갈 수 있음 | 비-admin 세션으로 `/admin/*` 접근 시 기존 리다이렉트 동작 불변 확인 |

---

## 5. 스코프 밖 (명시적 제외 — 별도 티켓 후보)

- 하드코딩 hex 90여 곳(레거시 다크 표면 전반) 토큰화
- 기능별 empty state 3계열(`AdminEmptyState`/`CatalogEmptyState`/profile `EmptyState`)의 shared 수렴
- `app/cody/*`·`search`·`about`·`login` 등 shell 밖 소비자 라우트의 에러 경계
- 오타 URL의 in-shell 404(catch-all shim) — D2에서 기각
- `global-error.tsx`의 `lang="ko"` 하드코딩(i18n) — 인라인 스타일은 불가피로 인정

---

## 연결

부모 맵 #918 · 감사 근거 `docs/design-system/redesign/shell.md`(R4/R6) · `docs/design-system/patterns.md` §3.2/3.3 · 병렬 디스패치 `docs/handoffs/2026-07-22-kiyori-parallel-dispatch-plan.md` §1 S5
Loading