{ "comment": "라우트별 변종 화면. 로그인 실패 / 검색 결과 유무 / 알림 유무 등. 사용자 board/shop/page 라우트는 코어 그룹 concat 으로 중복되지 않도록 각 모듈 editor-spec.json 에 일원화한다(A-4/A-5).", "groups": [ { "scope": { "kind": "route", "match": "/login" }, "items": [ { "id": "default", "label": "$t:editor.state.login_default", "default": true, "initialState": { "local": {} }, "formErrors": {} }, { "id": "login_failed", "label": "$t:editor.state.login_failed", "initialState": { "global": { "loginError": "이메일 또는 비밀번호가 올바르지 않습니다" } }, "comment": "검증 오류 경로는 로그인 폼이 실제로 읽는 상태 경로를 키로 직접 지정한다. 로그인 폼은 _global.loginErrors.{field} 를 읽으므로 그 경로로 주입.", "formErrors": { "_global.loginErrors.email": "이메일 또는 비밀번호가 올바르지 않습니다", "_global.loginErrors.password": "이메일 또는 비밀번호가 올바르지 않습니다" } } ] }, { "scope": { "kind": "route", "match": "/search" }, "comment": "검색 결과 영역은 searchResults 데이터소스가 condition `{{query?.q}}` 로 게이트된다(본문 line 62). 검색어가 없으면 결과 영역 자체가 렌더되지 않아 두 상태가 동일해 보이므로, 두 변종 모두 query.q 를 주입해 결과 영역을 띄운 뒤 데이터 유무로 변종을 구분한다. — searchActiveTab(기본 'all') 게이트로 게시글/상품/페이지 서브탭 본체가 분기된다(_search_results.json). 각 서브탭 상태를 추가해 탭별 프레젠테이션을 캔버스에 노출(클릭 불가, 상태 패치로 시뮬레이션). all 탭 posts/pages 섹션 본체는 searchResults.data.{posts,pages}.items 바인딩이라 base 샘플에 해당 배열을 채워 렌더 보장.", "items": [ { "id": "with_results", "label": "$t:editor.state.search_with_results", "default": true, "initialState": { "query": { "q": "티셔츠" } } }, { "id": "empty_results", "label": "$t:editor.state.search_empty", "initialState": { "query": { "q": "없는검색어" } }, "sampleDataOverrides": { "byDataSourceId": { "searchResults": { "data": { "data": [], "meta": { "total": 0, "per_page": 20, "current_page": 1, "query": "없는검색어" } } } } } }, { "id": "tab_posts", "label": "$t:editor.state.search_tab_posts", "initialState": { "query": { "q": "티셔츠" }, "global": { "searchActiveTab": "posts" } } }, { "id": "tab_products", "label": "$t:editor.state.search_tab_products", "initialState": { "query": { "q": "티셔츠" }, "global": { "searchActiveTab": "products" } } }, { "id": "tab_pages", "label": "$t:editor.state.search_tab_pages", "initialState": { "query": { "q": "티셔츠" }, "global": { "searchActiveTab": "pages" } } } ] }, { "scope": { "kind": "route", "match": "/mypage/notifications" }, "items": [ { "id": "with_notifications", "label": "$t:editor.state.notifications_with", "default": true }, { "id": "empty_notifications", "label": "$t:editor.state.notifications_empty", "comment": "override 는 base 를 통째 교체하므로 base 와 동일 shape(data.data 배열만 비움). unread_count/페이지네이션 메타도 빈 상태 정합값으로 채워 헤더가 깨지지 않게 한다.", "sampleDataOverrides": { "byDataSourceId": { "userNotifications": { "data": { "unread_count": 0, "current_page": 1, "last_page": 1, "per_page": 10, "total": 0, "data": [] } } } } } ] }, { "comment": "내 정보 수정 화면: 로그인 사용자가 비밀번호를 재확인하는 단계와 실제 정보 수정 단계로 구분된다. profile-edit 레이아웃은 _local.isPasswordVerified 로 두 partial(_password_verify_section / _edit)을 if 분기하므로, 그 상태 키를 initialState.local 로 주입해 각 단계를 미리 본다. 검증 오류는 폼이 읽는 실제 경로(_local.verifyPasswordError / _local.profileErrors.{field})로 주입한다.", "scope": { "kind": "route", "match": "/mypage/profile/edit" }, "items": [ { "id": "password_entry", "label": "$t:editor.state.profile_password_entry", "default": true, "initialState": { "local": { "isPasswordVerified": false } } }, { "id": "actual_edit", "label": "$t:editor.state.profile_actual_edit", "initialState": { "local": { "isPasswordVerified": true } } }, { "id": "validation_failed", "label": "$t:editor.state.profile_validation_failed", "initialState": { "local": { "isPasswordVerified": true } }, "formErrors": { "_local.profileErrors.name": [ "이름을 입력해 주세요" ], "_local.profileErrors.nickname": [ "이미 사용 중인 닉네임입니다" ], "_local.profileErrors.mobile": [ "올바른 휴대폰 번호 형식이 아닙니다" ] } } ] }, { "comment": "비밀번호 찾기. forgot_password 레이아웃은 {{_local.success}} 로 입력 폼/발송 완료 안내를 if 분기한다(본문 line 77/96).", "scope": { "kind": "route", "match": "/forgot-password" }, "items": [ { "id": "default", "label": "$t:editor.state.forgot_default", "default": true, "initialState": { "local": { "success": false } } }, { "id": "success", "label": "$t:editor.state.forgot_success", "initialState": { "local": { "success": true } } } ] }, { "comment": "비밀번호 재설정. reset_password 레이아웃은 _local.tokenError / _local.success 로 토큰오류·재설정폼·완료 화면을 if 분기한다(본문 line 307/390/446). 검증 오류는 폼이 읽는 _local.resetErrors.{field}?.[0] 경로로 주입.", "scope": { "kind": "route", "match": "/reset-password" }, "items": [ { "id": "token_valid", "label": "$t:editor.state.reset_token_valid", "default": true, "initialState": { "local": { "tokenError": false, "success": false } } }, { "id": "token_error", "label": "$t:editor.state.reset_token_error", "initialState": { "local": { "tokenError": true } } }, { "id": "success", "label": "$t:editor.state.reset_success", "initialState": { "local": { "tokenError": false, "success": true } } } ] }, { "comment": "본인인증 챌린지. identity_challenge 레이아웃은 _local.render_hint 값(text_code/link/external_redirect)으로 세 화면을 if 분기한다(본문 line 134/391/496).", "scope": { "kind": "route", "match": "/identity/challenge" }, "items": [ { "id": "text_code", "label": "$t:editor.state.challenge_text_code", "default": true, "initialState": { "local": { "render_hint": "text_code" } } }, { "id": "link", "label": "$t:editor.state.challenge_link", "initialState": { "local": { "render_hint": "link" } } }, { "id": "external_redirect", "label": "$t:editor.state.challenge_external", "initialState": { "local": { "render_hint": "external_redirect" } } }, { "id": "code_error", "label": "$t:editor.state.challenge_error", "comment": "코드 입력 화면에서 인증 실패 시 _local.error 배너가 표시된다(본문 line 164/175).", "initialState": { "local": { "render_hint": "text_code", "error": "인증번호가 올바르지 않습니다. 다시 시도해 주세요." } } } ] }, { "comment": "비밀번호 변경. change-password 레이아웃은 {{_local.success}} 로 입력 폼/완료 안내를 if 분기한다(본문 line 98/154).", "scope": { "kind": "route", "match": "/mypage/change-password" }, "items": [ { "id": "form_default", "label": "$t:editor.state.change_password_form", "default": true, "initialState": { "local": { "success": false } } }, { "id": "success", "label": "$t:editor.state.change_password_success", "initialState": { "local": { "success": true } } } ] }, { "comment": "찜 목록. _list partial 이 wishlist.data.data 길이로 그리드/빈 상태를 if 분기한다(line 60/110).", "scope": { "kind": "route", "match": "/mypage/wishlist" }, "items": [ { "id": "with_items", "label": "$t:editor.state.wishlist_with_items", "default": true }, { "id": "empty_wishlist", "label": "$t:editor.state.wishlist_empty", "sampleDataOverrides": { "byDataSourceId": { "wishlist": { "data": { "data": [], "pagination": { "total": 0, "per_page": 20, "current_page": 1 } } } } } } ] }, { "comment": "배송지 관리. _list partial 이 addresses.data.addresses.data 길이로 목록/빈 상태를 if 분기한다(line 55/179).", "scope": { "kind": "route", "match": "/mypage/addresses" }, "items": [ { "id": "with_addresses", "label": "$t:editor.state.addresses_with", "default": true }, { "id": "empty_addresses", "label": "$t:editor.state.addresses_empty", "sampleDataOverrides": { "byDataSourceId": { "addresses": { "data": { "addresses": { "data": [] } } } } } } ] }, { "comment": "회원 공개 프로필. users/show 레이아웃은 profile.data.status 가 'withdrawn' 인지로 일반/탈퇴 회원 정보를 if 분기한다(본문 line 187/269).", "scope": { "kind": "route", "match": "/users/:userId" }, "items": [ { "id": "active_user", "label": "$t:editor.state.user_active", "default": true, "comment": "override 는 base profile 샘플을 통째 교체하므로 base 와 동일 충실 shape 를 유지해야 한다(name/bio/created_at 누락 시 캔버스 헤더가 '-' 로 붕괴). status 만 active 로 고정.", "sampleDataOverrides": { "byDataSourceId": { "profile": { "data": { "uuid": "00000000-0000-4000-8000-000000000018", "name": "김민준", "nickname": "민준이", "avatar": "/images/placeholder.png", "bio": "프론트엔드 개발과 사진을 좋아하는 평범한 회원입니다.", "status": "active", "status_label": "활성", "created_at": "2025-09-14", "is_owner": false } } } } }, { "id": "withdrawn_user", "label": "$t:editor.state.user_withdrawn", "comment": "탈퇴 회원. base shape 통째 복제 후 status=withdrawn + name 을 탈퇴 표기로 변형(users/show line 269 탈퇴 분기 본체 미리보기). bio/created_at 도 유지해 헤더 붕괴 방지.", "sampleDataOverrides": { "byDataSourceId": { "profile": { "data": { "uuid": "00000000-0000-0000-0000-000000000099", "name": "탈퇴한 회원", "nickname": "탈퇴한 회원", "avatar": null, "bio": null, "status": "withdrawn", "status_label": "탈퇴", "created_at": "2024-03-02", "is_owner": false } } } } } ] }, { "comment": "회원 작성글. users/posts 레이아웃은 userPosts.data.data 길이로 목록/빈 상태를 if 분기한다(본문 line 198/226).", "scope": { "kind": "route", "match": "/users/:userId/posts" }, "items": [ { "id": "with_posts", "label": "$t:editor.state.user_posts_with", "default": true }, { "id": "no_posts", "label": "$t:editor.state.user_posts_empty", "sampleDataOverrides": { "byDataSourceId": { "userPosts": { "data": { "data": [], "total": 0, "current_page": 1, "per_page": 20 } } } } } ] }, { "comment": "회원가입. register 레이아웃은 _local.registerError 배너 + _local.errors.{field}?.[0] 인라인 필드 에러(이메일·비밀번호·이름·닉네임)로 검증 실패를 표시한다(본문 line 54/134/183/274). 메시지 값은 캔버스 translationEngine 이 해석하는 실제 콘텐츠 키($t:auth.register.error.*)를 사용한다(editor.* 파셜은 미리보기 사전에 없어 raw 키 노출).", "scope": { "kind": "route", "match": "/register" }, "items": [ { "id": "default", "label": "$t:editor.state.register_default", "default": true }, { "id": "valid_input", "label": "$t:editor.state.register_valid_input", "comment": "PasswordInput 의 비밀번호 일치 성공 표시(녹색 '비밀번호가 일치합니다')와 길이 통과(showValidation minLength:8)는 폼 상태 값 기반이다. 확인 필드는 confirmTarget `{{_local.registerForm?.password}}` 와 자기 값(registerForm.password_confirmation)이 같을 때 일치로 표시되므로, 두 값을 8자 이상 동일하게 시드해 성공 표시를 미리본다.", "initialState": { "local": { "registerForm": { "email": "user@example.com", "password": "password123", "password_confirmation": "password123", "name": "홍길동", "nickname": "gildong" } } } }, { "id": "validation_failed", "label": "$t:editor.state.register_validation_failed", "formErrors": { "_local.registerError": "$t:auth.register.error.email_exists", "_local.errors.email": [ "$t:auth.register.error.email_exists" ], "_local.errors.password": [ "$t:auth.register.error.password_min|count=8" ], "_local.errors.nickname": [ "$t:auth.register.error.nickname_required" ] } } ] }, { "comment": "마이페이지 게시판 활동: _my_posts / _my_comments 두 서브탭이 _global.boardActivitySubTab(기본 'my-posts') 게이트로 분기된다(_list.json line 291/306). 내댓글 본체(myComments, auto_fetch:false 지연소스)는 기본 상태로는 캔버스에 미렌더되므로 boardActivitySubTab 패치로 노출(탭 게이트). 편집기는 정적 시뮬레이션이라 샘플은 auto_fetch 무관하게 주입됨.", "scope": { "kind": "route", "match": "/mypage/board" }, "items": [ { "id": "my_posts", "label": "$t:editor.state.board_activity_my_posts", "default": true, "initialState": { "global": { "boardActivitySubTab": "my-posts" } } }, { "id": "my_comments", "label": "$t:editor.state.board_activity_my_comments", "initialState": { "global": { "boardActivitySubTab": "my-comments" } } } ] }, { "comment": " states 전수 — 주문/결제 배송지 관리 모달(_modal_address_manage, id=addressManageModal). 주소 검색 확장점(checkout_modal_address_search_slot)은 _local.addressModalMode === 'form' 게이트 뒤라 모달 편집 모드에서 이 상태로 연다. route scope 가 아니라 modal scope 인 이유: 확장 편집 모드의 shop/checkout 은 본문 확장점이 기본 노출되어 모달이 렌더되지 않고, 모달 내부 게이트는 모달 편집 모드(scope.kind=modal)에서 열어야 한다. /shop/checkout route 그룹(normal 등)은 ecommerce 모듈 스펙 소유 — 중복 정의 금지.", "scope": { "kind": "modal", "match": "addressManageModal" }, "items": [ { "id": "address_list", "label": "$t:editor.state.address_modal_list", "default": true }, { "id": "address_manual_form", "label": "$t:editor.state.address_modal_form", "initialState": { "local": { "addressModalMode": "form" } } } ] }, { "comment": " states 전수 — 주문 상세. 배송지 변경 모달(_modal_change_address)의 주소 검색 확장점(modal_change_address_search_slot)은 (_local.changeAddressMode ?? 'saved') === 'manual' 게이트 뒤. 확장 편집 모드의 모달 표시용 append 와 조합해 노출한다.", "scope": { "kind": "route", "match": "/mypage/orders/:order_number" }, "items": [ { "id": "default", "label": "$t:editor.state.order_show_default", "default": true }, { "id": "change_address_manual", "label": "$t:editor.state.order_change_address_manual", "initialState": { "local": { "changeAddressMode": "manual" } } } ] }, { "comment": " states 전수 — 공통 레이아웃. 모바일 메뉴 드로어(mobile_nav_drawer)는 _global.mobileMenuOpen 게이트(닫힘=translate-x-full 화면 밖)라 편집기 정적 시뮬레이션에서 편집 불가. 이 상태로 펼침을 시뮬레이션해 모바일 디바이스 탭과 조합 편집한다.", "scope": { "kind": "base", "match": "_user_base" }, "items": [ { "id": "default", "label": "$t:editor.state.user_base_default", "default": true }, { "id": "mobile_menu_open", "label": "$t:editor.state.mobile_menu_open", "initialState": { "global": { "mobileMenuOpen": true } } } ] } ] }