Skip to content

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: SettingsScreenpackages/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/false then syncs via useEffect once prefs resolves.
  • theme select — optimistic setThemeId immediately, pendingThemeIdRef tracks 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 a confirm.show.
  • developer mode hidden (bare glyph, no switch) vs revealed (switch + reset row) — gated by useDeveloperModeStore.
  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via an early OfflineEmptyState return — 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 the marketing_push PIPA 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: SettingsBlockedUsersScreenpackages/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), 3 SkeletonRow.
  • error — idsError only (profile-fetch errors are not separately surfaced) → QueryErrorState with retry.
  • empty — no blocked ids → EmptyState compact.
  • populated — bulk-resolved names via useProfilesByIds (Protocol A sibling); falls back to id.slice(0, 8) if a profile fails to resolve.
  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via QueryBoundary — never the empty state (2026-09-11 offline sweep).

Actions.

  • 차단 해제 → useConfirm naming the user → useUnblockUser({ userId }); button disabled while unblock.isPending.
  • back.

Gaps. none found.


Change Password (비밀번호 변경)

  • Route: /settings/change-password
  • Screen: ChangePasswordScreenpackages/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/touchedConfirm gate 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 → useConfirm dialog → appRouter.back().

Actions.

  • Edit the three fields.
  • Submit (disabled until isValid) → useChangePassword.mutate.
  • back.

Gaps. none found.


  • Route: /settings/consent/history
  • Screen: ConsentHistoryScreenpackages/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 line

States.

  • loading — FeedList's own loadingState: 3× SkeletonRow.
  • error — QueryErrorState via errorState prop, with retry.
  • empty — centered strings.empty text via emptyState prop.
  • populated — up to 200 rows (useConsentHistory(userId, 200, ...)); a row whose purposeKey JOIN failed falls back to a truncated purpose-id.
  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via the list's isPausedOffline + offlineState — never the empty state (2026-09-11 offline sweep).

Actions. back only — no row-level interaction, pure read log.

Gaps. none found.


  • Route: /settings/consent
  • Screen: SettingsConsentManagementScreenpackages/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) → OfflineEmptyState with retry, via QueryBoundary — never the empty state (2026-09-11 offline sweep).

Actions.

  • 철회 → confirm dialog naming the purpose → useRevokeConsent.
  • 활성화 → useGrantConsent directly, no confirm (mirrors the settings-screen marketing_push toggle; channel resolved from the per-jurisdiction matrix, falling back to null for 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: DeleteAccountScreenpackages/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 useConfirm dialog before the mutation fires.
  • deleting → CTA label "탈퇴 처리 중"-style, disabled.
  • error → Banner.
  • success → useDeleteAccount triggers sign-out (backend RPC sets deleted_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: EditProfileScreenpackages/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 → QueryErrorState with 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) → OfflineEmptyState with retry, via an early OfflineEmptyState return — 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 as null, 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: SettingsPrivacyScreenpackages/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 → useUpdateProfile with the full client-merged ProfileVisibility object (no server JSONB merge op).
  • 미리보기 → opens preview sheet; switch audience button.
  • back.

Gaps. none found.


Notification Settings (알림 설정)

  • Route: /settings/signal-preferences
  • Screen: SettingsSignalPreferencesScreenpackages/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-label

States. 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) → OfflineEmptyState with retry, via QueryBoundary — never the empty state (2026-09-11 offline sweep).

Actions.

  • Toggle a category's push SwitchuseUpdateSignalPreference.
  • Tap quiet-hours start/end chip → native time picker → confirm sets the shared window across all non-payment category rows.
  • back.

Gaps. none found.

Markdown remains the source of truth. Run yarn docs:check before handoff.