Screen Blueprints — Settings
Status: Active Last reviewed: 2026-09-11
Current-state blueprints for the settings screens, read from the components. Conventions and the screen index live in screen-blueprint.md. History lives in git and rebuild-log.md — not here.
Settings (설정)
- Route:
/settings - Screen:
SettingsScreen—packages/features/profile/src/settings-screen.tsx - Shell: DetailShell · title
t().settingsScreen.pageTitle· headerRight: none - Design: ratified
- Verified: 2026-09-16
Purpose. The settings hub: passive identity, appearance controls, nav to every sub-settings screen, app-info/legal links, and account-destructive actions.
Anatomy. Composition root wires data/mutation state into six section components under ./settings/* (god-file decomposition wave B2), each a peer inside one FlatColumn (flat canvas migration F4 — no boxed Cards, only FlatColumn's own hairline between sections).
├─ [DetailShell] title=pageTitle
└─ [FlatColumn]
├─ [IdentitySection] → [ListRow] avatar + displayName + email (no onPress, passive)
├─ [AppearanceSection] theme swatches ([ThemeOption] × THEME_IDS) + [SelectionChip] system/light/dark
├─ [SecondaryNavSection] → [RowList]
│ ├─ [SettingsRow] 알림 수신 [Switch] · 일정 리마인더 [Switch] · DM 푸시 미리보기 [Switch]
│ ├─ [SettingsRow] "프로필 편집" → routes.editProfile
│ ├─ [SettingsRow] "비밀번호 변경" → routes.changePassword
│ ├─ [SettingsRow] "알림 설정" → routes.signalPreferences
│ ├─ [SettingsRow] "프라이버시" → routes.settingsPrivacy
│ ├─ [SettingsRow] "동의 관리" → routes.consentManagement
│ ├─ [SettingsRow] "코트 제보 내역" → routes.venueContributions()
│ ├─ [SettingsRow] "누락된 코트 제보하기" → routes.newVenueContribution()
│ └─ [SettingsRow] "차단 목록" → routes.settingsBlockedUsers
├─ [AppInfoSection] → [RowList]
│ ├─ [SettingsRow] 둘러보기 다시 보기 → resets coachmark seen-flags
│ ├─ [SettingsRow] 공지사항 → routes.legalNotices
│ ├─ [SettingsRow] 문의하기 → routes.legalContact
│ ├─ [SettingsRow] 이용약관 → routes.legalTerms
│ ├─ [SettingsRow] 개인정보 처리방침 → routes.legalPrivacy
│ ├─ [SettingsRow] 앱 버전 (value display, no onPress)
│ └─ [SettingsRow] 업데이트 확인 → OTA check
├─ [DestructiveSection] → [RowList]
│ ├─ [SettingsRow] 로그아웃 (danger) → confirm → signOut
│ └─ [SettingsRow] 회원 탈퇴 (danger) → routes.deleteAccount
└─ ▸ [DeveloperSection] hidden Code2 glyph (long-press unlocks) | once enabled: [RowList] dev-mode [Switch] + 온보딩 리셋 [SettingsRow] (danger)States.
- prefs not yet loaded — local toggle state defaults
true/true/falsethen syncs viauseEffectonceprefsresolves. - theme select — optimistic
setThemeIdimmediately,pendingThemeIdReftracks in-flight write, rolls back to server value on mutation error. - OTA check — idle →
checkingUpdate(row value shows "다운로드 중"-style string) → result dialog (disabled/unavailable/ thrown error), each aconfirm.show. - developer mode hidden (bare glyph, no switch) vs revealed (switch + reset row) — gated by
useDeveloperModeStore. - Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via an earlyOfflineEmptyStatereturn — never the empty state (2026-09-11 offline sweep).
Actions.
- Toggle 알림 수신 / 일정 리마인더 / DM 푸시 미리보기 — each writes
useUpdatePreferences; 알림 수신 additionally logs a legacy signal-consent event and grants/revokes themarketing_pushPIPA purpose. - Select a theme chip —
useUpdatePreferences({ theme }), optimistic with rollback. - Select system/light/dark —
setColorScheme(local Zustand store only, no mutation). - 프로필 편집 / 비밀번호 변경 / 알림 설정 / 프라이버시 / 동의 관리 / 코트 제보 내역 / 누락된 코트 제보하기 / 차단 목록 → nav rows.
- 둘러보기 다시 보기 →
resetCoachmarks(). - 공지사항 / 문의하기 / 이용약관 / 개인정보 처리방침 → legal nav rows.
- 업데이트 확인 →
checkForAndApplyOtaUpdate(). - 로그아웃 → confirm →
signOut(). - 회원 탈퇴 →
appRouter.push(routes.deleteAccount). - (dev mode) toggle switch; 온보딩 리셋 → confirm → clears
profiles.onboarding_completed_at→ signs out.
Gaps. none found.
Blocked Users (차단 목록)
- Route:
/settings/blocked - Screen:
SettingsBlockedUsersScreen—packages/features/profile/src/settings-blocked-users-screen.tsx - Shell: DetailShell · title
strings.blockedUsersTitle· headerRight: none - Design: unreviewed
- Verified: 2026-09-17
Purpose. Lets the user view and one-tap unblock everyone they've blocked.
Anatomy.
├─ [DetailShell] title=blockedUsersTitle
└─ [FlatColumn]
├─ ▸ 3× [SkeletonRow] (loading)
├─ ▸ [QueryErrorState] (ids fetch error)
├─ ▸ [EmptyState] variant="compact" (blockedUsersEmpty)
└─ ▸ [RowList] of [ListRow] — [AvatarBubble] + name, trailing [ActionButton] "차단 해제"States.
- loading —
idsLoading || (blockedIds.length > 0 && profilesLoading), 3SkeletonRow. - error —
idsErroronly (profile-fetch errors are not separately surfaced) →QueryErrorStatewith retry. - empty — no blocked ids →
EmptyStatecompact. - populated — bulk-resolved names via
useProfilesByIds(Protocol A sibling); falls back toid.slice(0, 8)if a profile fails to resolve. - Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, viaQueryBoundary— never the empty state (2026-09-11 offline sweep).
Actions.
- 차단 해제 →
useConfirmnaming the user →useUnblockUser({ userId }); button disabled whileunblock.isPending. - back.
Gaps. none found.
Change Password (비밀번호 변경)
- Route:
/settings/change-password - Screen:
ChangePasswordScreen—packages/features/profile/src/change-password-screen.tsx - Shell: DetailShell · title
t().changePasswordScreen.pageTitle· headerRight: none - Design: unreviewed
- Verified: 2026-09-13
Purpose. Lets the authenticated user set a new password, current-password field shown for reassurance only (not client-validated against it).
Anatomy.
├─ [DetailShell] title=pageTitle · footer=[BottomCtaBand] ✦ submit [ActionButton]
└─ [FlatColumn]
├─ [LabeledField] 현재 비밀번호 → [StyledInput] secure
├─ [LabeledField] 새 비밀번호 → [StyledInput] secure (blur-validated)
├─ [LabeledField] 새 비밀번호 확인 → [StyledInput] secure (blur-validated)
└─ ▸ [Banner] tone="error" (submit error)States.
- untouched — no field errors shown (
touchedNew/touchedConfirmgate error display). - new-password errors after blur: too short (<8), no letter, no number.
- confirm-password mismatch after blur.
- server
INVALID_CURRENT_PASSWORD→ inline error under current-password field (clears on next keystroke there). - other server error → generic
Banner. - submitting → CTA disabled, label "저장 중"-style string.
- success →
useConfirmdialog →appRouter.back().
Actions.
- Edit the three fields.
- Submit (disabled until
isValid) →useChangePassword.mutate. - back.
Gaps. none found.
Consent History (동의 이력)
- Route:
/settings/consent/history - Screen:
ConsentHistoryScreen—packages/features/profile/src/consent-history-screen.tsx - Shell: DetailShell · title
strings.pageTitle· headerRight: none ·scroll={false} - Design: unreviewed
- Verified: 2026-09-14
Purpose. PIPA §39-7 / GDPR Art. 15 read-only audit trail: every consent event (grant/withdraw/expire/refuse/supersede) for the user, newest first.
Anatomy.
├─ [DetailShell] title=pageTitle · scroll=false
└─ [FeedList] perfLabel="consent-history"
└─ [HistoryRow] → [YStack] flat block, no Card (Hub-vs-detail law, owner-directed 2026-09-14;
spaced by FeedList's own item separator) — [ConsentStateBadge] + purpose label, timestamp,
collection-method line, ▸ expiry lineStates.
- loading —
FeedList's ownloadingState: 3×SkeletonRow. - error —
QueryErrorStateviaerrorStateprop, with retry. - empty — centered
strings.emptytext viaemptyStateprop. - populated — up to 200 rows (
useConsentHistory(userId, 200, ...)); a row whosepurposeKeyJOIN failed falls back to a truncated purpose-id. - Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via the list'sisPausedOffline+offlineState— never the empty state (2026-09-11 offline sweep).
Actions. back only — no row-level interaction, pure read log.
Gaps. none found.
Consent Management (동의 관리)
- Route:
/settings/consent - Screen:
SettingsConsentManagementScreen—packages/features/profile/src/settings-consent-management-screen.tsx - Shell: DetailShell · title
strings.pageTitle· headerRight: none - Design: unreviewed
- Verified: 2026-09-17
Purpose. PIPA §37 self-service consent management — see and revoke/re-grant each purpose the user has a record for.
Anatomy.
├─ [DetailShell] title=pageTitle
└─ [FlatColumn]
├─ ▸ 3× [SkeletonRow] (loading)
├─ ▸ [QueryErrorState] (error)
├─ ▸ [EmptyState] variant="compact" (empty)
├─ ▸ [RowList dividers] of [ConsentBlock] — flat block, no Card (Hub-vs-detail law, owner-directed
│ 2026-09-14) — purpose label, [ConsentRequirementBadge], [ConsentStateBadge], ▸ granted-at line,
│ ▸ required-caption + link → routes.deleteAccount, ▸ 철회/활성화 [ActionButton]
└─ ▸ [Pressable] "전체 내역 보기" → routes.consentHistory (only when consents exist)States. per-consent branch: required+granted → informational caption + delete-account link, no button; optional+granted → 철회 (revoke) button; optional+not-granted (withdrawn/refused/expired/superseded) → 활성화 (grant) button.
- Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, viaQueryBoundary— never the empty state (2026-09-11 offline sweep).
Actions.
- 철회 → confirm dialog naming the purpose →
useRevokeConsent. - 활성화 →
useGrantConsentdirectly, no confirm (mirrors the settings-screen marketing_push toggle; channel resolved from the per-jurisdiction matrix, falling back tonullfor a non-'KR' jurisdiction). - required-consent caption link →
routes.deleteAccount. - 전체 내역 보기 →
routes.consentHistory. - back.
Gaps. none found.
Notes. 2026-09-17: consent-block hairline is the shared Divider (PD-05 sweep) — pixel-identical.
Delete Account (회원 탈퇴)
- Route:
/settings/delete-account - Screen:
DeleteAccountScreen—packages/features/profile/src/delete-account-screen.tsx - Shell: DetailShell · title
t().deleteAccountScreen.pageTitle· headerRight: none - Design: unreviewed
- Verified: 2026-09-13
Purpose. Soft-delete flow gated by a typed confirmation phrase plus a second confirm dialog.
Anatomy.
├─ [DetailShell] title=pageTitle · footer=[BottomCtaBand] ✦ [Button] variant="destructive"
└─ [FlatColumn]
├─ [Banner] tone="warning" — 3 consequence bullets
├─ [YStack] instruction text + [StyledInput] (typed phrase)
└─ ▸ [Banner] tone="error" (submit error)States.
- phrase mismatched/empty → CTA disabled.
- phrase matches → CTA enabled.
- pressing CTA → second
useConfirmdialog before the mutation fires. - deleting → CTA label "탈퇴 처리 중"-style, disabled.
- error →
Banner. - success →
useDeleteAccounttriggers sign-out (backend RPC setsdeleted_at, withdraws consents, clears push tokens; 30-day anonymization cron does final erasure).
Actions. type the confirm phrase; CTA → confirm dialog → useDeleteAccount.mutate({ userId }); back.
Gaps. none found.
Edit Profile (프로필 수정)
- Route:
/settings/edit-profile - Screen:
EditProfileScreen—packages/features/profile/src/edit-profile-screen.tsx - Shell: DetailShell · title
t().editProfileScreen.pageTitle· headerRight: none - Design: ratified
- Verified: 2026-09-17
Purpose. Lets the user edit displayName, gender, region, 구력 (tennis start year), 출생 연도 (birth year, write-once), and avatar.
Anatomy.
├─ [DetailShell] title=pageTitle · footer=[BottomCtaBand] ✦ 저장하기 [ActionButton]
└─ [FlatColumn] lead: avatar block
├─ lead: [AvatarBubble size=2XL imageUrl=avatarUrl name=displayName] (the same bubble + `deriveInitials` as the profile hero — 'DE', never a one-letter local derivation) + ▸ upload-pending spinner overlay (absolute sibling)
│ + [CameraBadge] bottom-right (pick/upload) + ▸ [RemoveIconButton] top-right (remove, confirm)
├─ [LabeledField] 이름 (labelRight: char counter) → [StyledInput] maxLength=20
├─ [LabeledField] 성별 → [ChipButton]×3 (남성/여성/비공개, local sub-component, NOT the canonical SelectionChip)
├─ [LabeledField] 지역 → [ChipButton]×17 (SIDO_KEYS-derived, toggle off on re-tap)
├─ [LabeledField] 구력 → [StyledInput] numeric, 4-digit, range-validated
├─ [YStack] [LabeledField] 출생 연도 → [StyledInput] numeric, 4-digit; `editable={false}` once `profile.birthYear` is set + helper/locked caption below
└─ ▸ [Banner] tone="error" (save error)States.
- profile loading →
SkeletonHero+ 3×SkeletonCard(own branch,DetailShell scroll— no footer). - profile load error →
QueryErrorStatewith retry (own branch, no footer). - avatar-picker permission denied → confirm dialog.
- avatar uploading → spinner overlay on the avatar circle.
- birth year locked (already set server-side, migration 00186 trigger) vs unlocked (first-time, still editable).
- field errors: name length (2–20), 구력 out of range (1940–current year), 출생 연도 out of range or under-14.
- not dirty → save disabled; dirty + valid → enabled; saving → disabled with "저장 중" label; save error →
Banner; save success → confirm dialog → back. - Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via an earlyOfflineEmptyStatereturn — never the empty state (2026-09-11 offline sweep).
Actions.
CameraBadge→ image-library pick → resize →useUploadAvatar.RemoveIconButton(own built-in confirm) →useRemoveAvatar.- edit 이름 / select 성별 chip / select-or-deselect 지역 chip / type 구력 / type 출생 연도 (only while unset).
- 저장하기 →
useUpdateProfile.mutate(birth year key omitted entirely from the payload once locked, not sent asnull, to avoid tripping the immutability guard) → success confirm → back. - back.
Gaps. none found.
Notes. 2026-09-17: avatar pick routes through usePickImages (one permission dialog, crop, resize) — no visual change.
Profile Visibility (프라이버시)
- Route:
/settings/privacy - Screen:
SettingsPrivacyScreen—packages/features/profile/src/settings-privacy-screen.tsx - Shell: DetailShell · title
s.pageTitle· headerRight: none - Design: unreviewed
- Verified: 2026-09-13
Purpose. Per-field profile visibility control (avatar/region/district/playRegions/gender/birthYear), each set to one of 4 audience levels, with a live preview.
Anatomy.
├─ [DetailShell] title=pageTitle
└─ [FlatColumn] lead: subtitle + 미리보기 [ActionButton] (Eye icon) → opens sheet
└─ [SectionBlock] variant="flat" title=sectionFieldsTitle
└─ [FieldRow] × 6 (region/district/avatar/playRegions/gender/birthYear), each label + ▸ description + [SelectionChip]×4 + level-description caption, separated by [Divider inset="full"]
└─ [ModalPanel] ProfilePreviewSheet — audience switcher ([PreviewAudienceButton]×3: stranger/club_member/friend) + [PreviewFieldRow] list (visible/hidden [Badge] + value or hidden placeholder)States. no explicit loading/error branch — DEFAULT_VISIBILITY merges with profile?.profileVisibility so every field always renders a level even pre-load. Preview sheet recomputes visible/hidden per row purely client-side from current visibility + selected audience.
Actions.
- Tap a visibility chip per field →
useUpdateProfilewith the full client-mergedProfileVisibilityobject (no server JSONB merge op). - 미리보기 → opens preview sheet; switch audience button.
- back.
Gaps. none found.
Notification Settings (알림 설정)
- Route:
/settings/signal-preferences - Screen:
SettingsSignalPreferencesScreen—packages/features/profile/src/settings-signal-preferences-screen.tsx - Shell: DetailShell · title
s.settingsPageTitle· headerRight: none - Design: unreviewed
- Verified: 2026-09-13
Purpose. Per-category control of whether a notification category ALSO sends a phone push (in-app delivery is always on, not toggleable here), plus one shared quiet-hours window (payment exempt).
Anatomy.
├─ [DetailShell] title=settingsPageTitle
└─ [FlatColumn] lead: explainer text
├─ ▸ 3× [SkeletonRow] (loading)
├─ ▸ [QueryErrorState] (error)
├─ [QuietHoursRow] — Moon icon, label+desc, start/end pill buttons → opens [DateTimePickerModal]
└─ [RowList] of [CategoryPushRow] — icon bubble, category label, "인앱" [Badge], push [Switch]; payment row shows a bypass-quiet-hours sub-labelStates. loading; error with retry; per-category optimistic local rows mirror (resyncs via useEffect once server round-trips); quiet-hours picker open (start/end) vs closed.
- Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, viaQueryBoundary— never the empty state (2026-09-11 offline sweep).
Actions.
- Toggle a category's push
Switch→useUpdateSignalPreference. - Tap quiet-hours start/end chip → native time picker → confirm sets the shared window across all non-payment category rows.
- back.
Gaps. none found.