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 (설정, L4-15)
- Route:
/settings - Screen:
SettingsScreen—packages/features/profile/src/settings-screen.tsx(pieces insettings/) - Shell: DetailShell navRoot · title
t().common.settings - Design: unreviewed
- Verified: 2026-11-14
- Batch 58 (ST3, 2026-11-13), States:
ownProfileState+PageStateViewunder the page's Shell: loading (identity row + three group skeletons), cold offline (the one offline state), a failed or row-less read (error, one retry); a cached profile offline shows the page under the root strip.
Purpose. Every setting in one list of Cells; the danger zone is last. W4 batch 20, frame-checked against forms#st-* (L4-15).
Anatomy.
├─ [DetailShell] navRoot · title=설정
└─ gate → ▸ cached, offline: unchanged under the root strip · [PersonRow context=accounts] (passive: name, email)
├─ 계정 → 프로필 수정 · 비밀번호 변경
├─ 알림 → 알림 설정
├─ 프라이버시 → 공개 범위 설정 · 동의 관리 · 차단한 사용자
├─ 앱 → 보낸 제안 · 화면 모드 (value) · 둘러보기 다시 보기 (command) · 앱 버전 (static) · 업데이트 확인 (command)
├─ 지원 → 공지사항 · 문의하기 · 이용약관 · 개인정보 처리방침
├─ ▸ 개발자 (after seven presses on 앱 버전)
└─ [CellGroup top=false] 로그아웃 · 회원 탈퇴 (destructive, no head)
[ModeSheet] three radiosStates. loading skeleton (person + three groups) · offline cold OfflineEmptyState · load error QueryErrorState with one retry · ideal · cached while offline (the root strip over unchanged Cells) · update check busy (siblings wait; failure is the line on the Cell, a result is a Toast). No Switch and no CTA: every push switch is 알림 설정's.
- Offline (F1c, the app's root strip says it): offline with a cached profile the Cells stay unchanged; 로그아웃 stays live (signing out needs no signal: offline it ends the session on this device through
signOutThisDevice, without waiting for the server), 온보딩 초기화 waits (a server write), 회원 탈퇴 only opens its page, the developer switch is local (st-stale). - 2026-11-13 (UX-7, the state helper):
ownProfileState+PageStateViewunder the page's Shell: loading (identity row + three group skeletons), cold offline (the one offline state), a failed or row-less read (error, one retry); a cached profile offline shows the page under the root strip.
Actions. Rows push their pages (selectedRoute marks the open one in the web list-detail). 보낸 제안 → 장소 정보 제안 내역 (scope=mine). 화면 모드 → the three-radio Sheet, closing on a pick. 둘러보기 다시 보기 resets the coachmarks. 업데이트 확인 runs the check. 앱 버전 x7 reveals 개발자. 로그아웃 asks a Dialog (닫기 / 로그아웃). 회원 탈퇴 → its page.
Gaps. 연결된 계정 is not drawn: no dead row until provider sign-in exists, and L4-16 returns with it. 본인인증 (on the board) is not drawn: no identity-check read exists.
Blocked Users (차단한 사용자)
- Route:
/settings/blocked - Screen:
SettingsBlockedUsersScreen—packages/features/profile/src/settings-blocked-users-screen.tsx - Shell: DetailShell · title
t().common.blockedUsers· no actions - Design: unreviewed
- Verified: 2026-11-14
- Notes: 2026-11-14 (batch 61): the confirm Dialog sits outside the FlatColumn and the rows carry first/last, so no divider draws under the last person.
- Batch 58 (ST3, 2026-11-13), States:
pageState+PageStateView: five person rows loading, cold offline, error (what 차단한 사용자); first-run is the page's EmptyState; a cached list offline stays.
Purpose. List the people the viewer blocked and unblock one from its row. Built W4 batch 5 to flat-v2-list-b#bk-ideal (L3-20).
Now. useBlockedUsers ids + one bulk useProfilesByIds; an unresolved profile prints the id's first eight characters. Only the ids can fail the page (a profiles failure falls back to the ids). Phase by useCompositeNetwork([ids]).
Anatomy.
├─ [DetailShell]
└─ offline | error | loading | empty | list
list → [FlatColumn] → [RailBleed] → [PersonRow context="blocked" control 차단 해제]×NStates. loading SkeletonKind person×5 (ids, then profiles) · error QueryErrorState what=차단한 사용자 with one retry · offline OfflineEmptyState (cold or cached) · first-run EmptyState first glyph user (차단한 사용자가 없어요 · 차단하면 여기에서 해제할 수 있어요).
Write failures (ruling EP-3): a refused 차단 해제 is told in a Dialog (닫기 resets the write); never a Toast.
Offline (F1c, the app's root strip says it): offline with the ids cached the list stays (bk-stale); cold offline is the offline state.
2026-11-13 (UX-7, the state helper):
pageState+PageStateView: five person rows loading, cold offline, error (what 차단한 사용자); first-run is the page's EmptyState; a cached list offline stays.
Actions. Row → 공개 프로필. Control (aria "{name} 차단 해제") opens the Dialog "{name}님을 차단 해제할까요?" body 다시 메시지를 주고받을 수 있어요, buttons 닫기 · 차단 해제 (primary, reversible); useUnblockUser toasts the result.
Gaps. L2-06's friendship band reads the legacy friendships.status='blocked' while blocks live in user_blocks (L2-06's task). The result Toast dm.unblockUserSuccess still ends in a period (dm namespace). A cached page stays under the root strip when offline (owner decision 16, 2026-11-10).
Change Password (비밀번호 변경, L4-12)
- Route:
/settings/change-password - Screen:
ChangePasswordScreen—packages/features/profile/src/change-password-screen.tsx(pieces inchange-password/) - Shell: DetailShell · title
t().common.changePassword· footer CtaBand 변경하기 - Design: unreviewed
- Verified: 2026-11-15
- Notes: 2026-11-14 (batch 61): the submit button reads 변경하기 (
changePasswordScreen.submit), the verb with 하기 as the component library's section 1.19 rules (the first ruling, 변경, was corrected the same day).
Purpose. Change the sign-in password from the current one. Rebuilt to forms#pw (L4-12, W4 batch 20).
Anatomy.
├─ [DetailShell] title=비밀번호 변경 · footer=[CtaBand] ✦ primary 변경하기
└─ ▸ [FieldColumn]
├─ [Field required] 현재 비밀번호 → [Input password]
├─ [Field required] 새 비밀번호 → [Input password]
└─ [Field required] 새 비밀번호 확인 → [Input password]States. Reward early, punish late: no error on the first keystrokes, a check on leaving a field, a live re-check once wrong, every error marked on a press; each error is its own field's line (no summary Notice). Rule: 8+ characters with a letter and a number. Band never soft, loads (변경 중...), disabled offline (strip). Server wrong-current lands under the current field; any other failure is the band's danger helper. Typed form arms the leave Dialog (passwords are not kept).
- Offline (F1c, the app's root strip says it): offline the form stays and the band waits (the kit holds the primary); no page strip.
Actions. 변경하기 → useChangePassword (silent) → pop back + Toast 비밀번호가 변경됐어요. Back (guarded when typed).
Gaps. The first invalid field does not take programmatic focus (the kit Input has no focus handle).
Consent History (동의 이력)
- Route:
/settings/consent/history - Screen:
ConsentHistoryScreen—packages/features/profile/src/consent-history-screen.tsx - Shell: DetailShell · title
t().consent.history.pageTitle· scroll=false · no actions - Design: unreviewed
- Verified: 2026-11-13
Purpose. A read-only audit trail of every consent event, newest first. Built W4 batch 5 to flat-v2-list-b#cs-ideal (L3-21).
Now. useConsentHistory(userId, 200) in a FeedList (tail-only SCROLL_CONTENT.list). Each event is a ui TextRow: l1 the purpose (or "…" and the id's last eight), l2 [formatDateTimeLong moment with the 24-hour clock, collection method], extra the expiry '{date} 만료' (date only, formatDateLong), the state as the trailing Tag (tag/tagLabel, ConsentStateBadge). Rows are not links. The 200-row cap stays: there is no paging past it.
Anatomy.
├─ [DetailShell] scroll=false
└─ [FeedList] → [TextRow]×N (l1, l2, extra expiry, tag state)States. loading TextRow loading×7 · error QueryErrorState what=동의 이력 · offline OfflineEmptyState · first-run EmptyState first glyph calendar (동의 이력이 없어요 · 동의하거나 철회하면 여기에 남아요). All five states draw (동의함, 철회됨, 거절됨, 갱신됨, 만료됨). Method re_consent_modal reads 다시 동의 요청.
- 2026-11-13 (UX-7, the state helper): Weights follow the role (no hand-set font weight); no state changed.
Actions. None (back only).
Gaps. 거절됨 and 갱신됨 have no writer, and grant_consent never sets expires_at, so those states and the expiry line cannot appear until a PIPA consent task adds writers. A cached page stays under the root strip when offline (owner decision 16, 2026-11-10).
Consent Management (동의 관리, L4-19)
- Route:
/settings/consent - Screen:
SettingsConsentManagementScreen—packages/features/profile/src/settings-consent-management-screen.tsx(pieces insettings-consent/) - Shell: DetailShell · title
t().common.consentManagement· no CTA - Design: unreviewed
- Verified: 2026-11-13
- Batch 63 (L16, N5), Anatomy: 필수 동의 holds every purpose the sign-up catalog marks required (the consent matrix, not
legal_basis): 이용약관, 개인정보 처리방침, 국외 이전; none has 철회, and the head's note says they end only with 회원 탈퇴. The server refuses the withdrawal of a required purpose too (00774,consent_required); a refusal shows the same line on the row. - Batch 58 (ST3, 2026-11-13), States:
QueryBoundary offline="hidden": consent records are a private read, so offline is the offline state even with a warm copy; loading, error unchanged.
Purpose. PIPA self-service: see each consent, withdraw or grant an optional one. Rebuilt to forms#cn (L4-19, W4 batch 20).
Anatomy.
├─ [DetailShell] title=동의 관리 · no CTA
└─ [QueryBoundary]
├─ 필수 동의 → [Cell]×N label "[필수] …" line2 granted-at · trailing [DoorPill 보기]
├─ 선택 동의 → [Cell]×N label "[선택] …" line2 · trailing [Button tonal sm 철회 | 동의] · expand [DoorPill 보기]
└─ [Cell nav] 동의 이력States. loading SkeletonKind cell×6 · load error with one retry · offline cold via the boundary · ideal. No empty state: the required consents exist from sign-up. In flight: the button spinner (common.processing). A failure is the line on its row, never a Toast.
- 2026-11-13 (UX-7, the state helper):
QueryBoundary offline="hidden": consent records are a private read, so offline is the offline state even with a warm copy (a stale copy of a legal choice must not look current); loading, error unchanged.
Actions. 보기 → 이용약관 (terms) or 개인정보 처리방침 (every other purpose, including 국외 이전, until a per-consent Document page exists). 동의 → Dialog of two equal choices repeating the consent sentence (닫기 / 동의), then Toast 동의했어요. 철회 → Dialog naming the purpose and the way back (닫기 / 철회). 동의 이력 → /settings/consent/history.
Gaps. No per-consent Document page, so 보기 on an optional row opens the privacy policy.
Delete Account (회원 탈퇴, L4-13)
- Route:
/settings/delete-account - Screen:
DeleteAccountScreen—packages/features/profile/src/delete-account-screen.tsx(pieces indelete-account/) - Shell: DetailShell · title
t().common.deleteAccount· footer CtaBand 탈퇴하기 (danger) - Design: unreviewed
- Verified: 2026-11-14
- Notes: 2026-11-14 (batch 61): the submit button reads 탈퇴하기 (
accountForms.deleteAccount.submit), as section 1.19 rules (the first ruling, 탈퇴, was corrected the same day).
Purpose. The one-way exit: say what leaving does, ask the typed phrase, delete. Rebuilt to forms#da (L4-13, W4 batch 20).
Anatomy.
├─ [DetailShell] title=회원 탈퇴 · footer=[CtaBand] ✦ danger 탈퇴하기 (absent for a 대표)
└─ ▸ the form
├─ form → [CellGroup] static Cell×5 (club, sessions, fees, records, recovery) → [Field required] phrase → [Input]
└─ owner wall → [Notice attention] + [SectionHead count] → [Cell nav]×owned clubsStates. Form: band soft until the exact phrase, loads (탈퇴하는 중...), failure is its danger helper, disabled offline and until useMyOwnedClubs has answered. Owner wall: a 대표 of any club sees the Notice and a Cell per club instead of the form and band. A typed phrase arms the leave guard. No query-error branch: the owned-clubs read defaults to none.
- Offline (F1c, the app's root strip says it): offline the form stays and the band waits (the kit holds the primary); no page strip.
Actions. Typing the exact phrase is the confirmation; no Dialog follows. 탈퇴하기 → useDeleteAccount (atomic RPC: deleted_at, consents withdrawn, push tokens cleared; the 30-day cron anonymizes), then sign-out. An owned-club Cell → 클럽 설정 (where 대표 권한 넘기기 is).
Gaps. The owner wall is the page's rule (board da-owner), not the server's: if the write were reached, the server would hand each club to its earliest 운영진 or archive it (private.deactivate_departed_member_state, 00407).
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: unreviewed
- Verified: 2026-11-13
- Batch 58 (ST3, 2026-11-13), States:
ownProfileState+PageStateViewunder the Shell: loading (photo row + two groups), cold offline, error with one retry (also a fetched read with no row); a cached profile offline shows the form. - Notes: 2026-11-02 (K5b): the hero face is the K5a
Avatarat 96 (was anAvatarBubble2XL, 88). 2026-10-19 (W2a): the screen's SelectionChip sets are ChoiceSets of Choices (the codemod: a named radiogroup or group, one tab stop, 44 tall in a form).
Purpose. Lets the user edit displayName, gender, region, 구력 (tennis start year), 출생 연도 (birth year, write-once), and avatar.
Anatomy.
├─ [DetailShell] title=pageTitle · footer=[CtaBand] ✦ primary 저장하기
└─ [FlatColumn] lead: avatar block
├─ lead: [Avatar size=96 photoUrl=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) + ▸ [IconButton danger] top-right (remove, confirm)
├─ [Field] 이름 → [Input] limit=20 (the counter is the Field's)
├─ [Field] 성별 → [Choice]×3 (남성/여성/비공개)
├─ [Field] 지역 → [DropdownChip standalone fullWidth] (H5 fix 2026-09-23 — was a 17-[Choice] wall; SIDO_KEYS-derived options, allLabel/placeholder = 비공개, `onChange` clears to null via the built-in 전체 row)
├─ [Field] 구력 → [Input] numeric, 4-digit (`maxLength`, a fixed format), range-validated
├─ [YStack] [Field] 출생 연도 → [Input] numeric, 4-digit; `disabled` once `profile.birthYear` is set + helper/locked caption below
└─ ▸ [RailBleed > Notice] tone="danger" title=saveErrorTitle body=server message (save error)States.
Write failures (ruling EP-3): a photo that fails to upload or come off is the CTA band's danger helper; saving the name says its line on the helper; never a Toast.
Offline (F1c, the app's root strip says it): offline with a cached profile the form stays and 저장 and the photo write rows wait (the kit holds them).
profile loading →
SkeletonKind field×4 (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 → a danger
Notice; 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).unsaved edits (2026-09-27, LEAVE-GUARD) →
useUnsavedChangesGuard(isDirty && !saving): the iOS swipe-back is locked and any other exit asks the app's one leave question (나갈까요? · 계속 작성 / 삭제하고 나가기); the post-save 확인 callsrelease()before going back. The avatar's upload/remove commit on their own and never count as unsaved.2026-11-13 (UX-7, the state helper):
ownProfileState+PageStateViewunder the Shell: loading (photo row + two groups), cold offline, error with one retry (also a fetched read with no row); a cached profile offline shows the form.
Actions.
CameraBadge→ image-library pick → resize →useUploadAvatar.RemoveIconButton(own built-in confirm) →useRemoveAvatar.- edit 이름 / select 성별 chip / pick-or-clear 지역 (DropdownChip) / 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.
2026-09-30 (Tamagui 2). Accessibility props moved to the web vocabulary (aria-label, role, aria-*) and Text's typography variant role became textRole — mechanical renames; no layout or behaviour change.
Gaps. none found.
Notes. 2026-09-17: avatar pick routes through usePickImages (one permission dialog, crop, resize) — no visual change.
Privacy Settings (공개 범위 설정, L4-17)
- Route:
/settings/privacy - Screen:
SettingsPrivacyScreen—packages/features/profile/src/settings-privacy-screen.tsx(pieces insettings-privacy/) - Shell: DetailShell · title
t().settingsPrivacy.pageTitle· no CTA - Design: unreviewed
- Verified: 2026-11-15
- Region preview: the preview sheet shows the region in words (
regionLabel), never the stored key. - Batch 58 (ST3, 2026-11-13), States:
ownProfileState+PageStateViewunder the Shell: loading (seven Cells), cold offline, error with one retry; a cached profile offline shows the Cells.
Purpose. Choose who sees each part of the profile and preview it per audience. Rebuilt to forms#pv (L4-17, W4 batch 20).
Anatomy.
├─ [DetailShell] title=공개 범위 설정 · no CTA
└─ ▸ [Cell command] 공개 화면 미리보기
└─ [SectionHead] 정보별 공개 범위 → [Cell value]×7 (프로필 사진, 지역, 구, 활동 지역, 성별, 출생 연도, 매치 기록)
[ValueSheet] four radio Cells + meanings · [Sheet] preview: [ChoiceSet] 모르는 사람 / 같은 클럽 / 친구 → [Cell static]×7 + 신뢰 점수States. loading SkeletonKind cell×7 · offline cold OfflineEmptyState · load error with one retry · ideal · cached while offline (strip; the field Cells are inert). Preview: 보임 or 숨김 Tag per field, hidden falls back muted (the photo to the initials); 신뢰 점수 is always visible, 최근 20회 중 참석 for the club and friend audiences.
- Offline (F1c, the app's root strip says it): offline the Cells stay and the seven field Cells wait (write Cells); 공개 화면 미리보기 still opens.
- 2026-11-13 (UX-7, the state helper):
ownProfileState+PageStateViewunder the Shell: loading (seven Cells), cold offline, error with one retry; a cached profile offline shows the Cells.
Actions. A field Cell → the level Sheet titled with the field (전체 공개, 같은 클럽, 친구만, 비공개); a pick writes at once through usePatchProfileVisibility (patch_profile_visibility, one field per call) and closes. 공개 화면 미리보기 → the preview Sheet.
Gaps. 매치 기록 (migration 00704) is a seventh row the board predates.
Notification Settings (알림 설정)
- Route:
/settings/signal-preferences - Screen:
SettingsSignalPreferencesScreen—packages/features/profile/src/settings-signal-preferences-screen.tsx; the page's rules and Cells are inpackages/features/profile/src/settings-notifications/ - Shell: DetailShell · title
common.notificationSettings· headerRight: none - Design: unreviewed
- Verified: 2026-11-11
- Notes: W4 batch 18 (rulings N7, N8, N9): rebuilt on its board; the page is the one place for every push.
Purpose. Turn pushes on or off, choose which notifications also reach the phone, when to stay quiet, and how much of each club comes through. Every notification still lands in 알림 (메시지 is push only: chat stays out of the inbox).
Anatomy.
├─ [DetailShell] title=알림 설정
└─ [QueryBoundary] queries: signal preferences, user preferences, my clubs, my club levels
├─ ▸ [SkeletonKind cell]×8 (loading) · [QueryErrorState] (error) · [OfflineEmptyState] (cold offline)
├─ [CellGroup]
│ ├─ [Cell toggle] 푸시 알림 (the master: `user_preferences.notifications_enabled`; off hides everything below, every choice kept)
│ └─ [Cell value] 방해 금지 시간 "22:00~08:00" (line 2: the payment pushes ignore it) → [TimeRangeSheet] (시작/종료 tabs, 24-hour, may wrap midnight, 저장)
├─ [SectionHead] 푸시로 받을 알림
├─ [CellGroup]
│ ├─ [Cell toggle]×7 일정 · 경기 · 회비와 참가비 · 클럽 · 사람 · 시스템 · 메시지 (line 2: what it covers)
│ └─ ▸ [Cell toggle] 알림에 메시지 내용 보이기 (only while 메시지 is on; `dm_push_preview_enabled`)
└─ ▸ [SectionHead] 클럽별 알림 · [CellGroup] [Cell value]×clubs (전체 / 공지만 / 끔) → [ValueSheet] (each level with its meaning)States. ideal; saving (the family and content Cells are inert while the batch writes); a write failed (the Switch rolls back, the failure line common.saveFailed sits on the Cell that wrote: the family, the master, the content switch, the quiet hours or the club); master off; 메시지 off; loading; load error with one retry; offline cold; no club (no 클럽별 알림 section); the two Sheets.
- Offline (F1c, the app's root strip says it): offline with cached preferences the page stays (sg-stale); the family switches except 일정 queue durably and stay live (counted in the strip's N건 대기 중), as does 방해 금지 시간; the master switch, 일정, 메시지 내용 and the per-club level Cells write the preferences row, which does not queue, so the kit holds them.
Actions.
- A family Cell →
useUpdateSignalPreferences: one batch writingpush_enabledon every category of the family (familyPushWrites); a family shows on when any of its categories is on (일정 = session, rsvp · 경기 = matchup, rating, progression · 회비와 참가비 = dues, payment · 클럽 = club, interclub · 사람 = social, trust · 시스템 = system · 메시지 = chat). Storage stays 13 rows. - 방해 금지 시간 → the range Sheet; 저장 writes the window to every non-payment category (payment is exempt) in one batch.
- 푸시 알림 and 알림에 메시지 내용 보이기 →
useUpdatePreferences(optimistic); the master also logs a delivery-preference line (signal_consent_log, category system), never a consent grant. - A club Cell → the shared level Sheet (
ClubNotifyLevelSheetfrom @twomore/app, also opened from 클럽 홈); a pick →useSetClubNotifyLevel(set_club_notify_level) and the Sheet closes (the write is fire-and-forget, the Cell carries its failure). A club's level governs its low-severity pushes only (공지만 keeps notices; payments, cancellations and requests are never muted by it). - back.
Recorded leftover. 일정 리마인더 (notify_session_reminders) has no switch any more; its emission gate stays as it was until a later batch folds it into the 일정 family's write.
Gaps. none found.
2026-10-06 (Codex landing D3). Toggles and the quiet-hours window save through one batched useUpdateSignalPreferences write; the query cache owns optimism and rollback.