diff --git a/docs/handoffs/2026-07-22-918-error-surface-handoff.md b/docs/handoffs/2026-07-22-918-error-surface-handoff.md new file mode 100644 index 000000000..548f7e36d --- /dev/null +++ b/docs/handoffs/2026-07-22-918-error-surface-handoff.md @@ -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` diff --git a/docs/handoffs/README.md b/docs/handoffs/README.md index cbf4433d4..cad8db125 100644 --- a/docs/handoffs/README.md +++ b/docs/handoffs/README.md @@ -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) | diff --git a/docs/superpowers/specs/2026-07-22-error-surface-compliance-decisions.md b/docs/superpowers/specs/2026-07-22-error-surface-compliance-decisions.md new file mode 100644 index 000000000..1fadc135b --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-error-surface-compliance-decisions.md @@ -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