Screen Blueprints — Profile
Status: Active Last reviewed: 2026-09-11
Current-state blueprints for the profile 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.
Profile (프로필)
- Route:
/(tabs)/(profile) - Screen:
ProfileScreen—packages/features/profile/src/profile-screen.tsx - Shell: MainTabShell · title
t().profileScreen.pageTitle(프로필) · ground=canvas· headerRight: DM inboxHeaderIconButton(unread dot) + SettingsHeaderIconButton - Design: ratified
- Verified: 2026-09-17
Purpose. The viewer's own identity/social/settings home — ELO, stats, and entry points into friends, achievements, attendance, manner tags, and app settings.
Anatomy. Flat detail-page migration (canvas ground, no boxed hero card). Every critical-path query fires at the screen root and is gated by one QueryBoundary (skipDisabled) — the whole body renders after everything settles, no cascading per-section reveal:
├─ [MainTabShell] ground="canvas" title=프로필 headerRight=[DM icon+dot, Settings icon]
└─ [ScrollView] + [QueryBoundary loading=[SkeletonFlatPage kind="type"]]
├─ [TypeHero] leading=[AvatarBubble size=HERO], title=displayName,
│ titleBadge=[TrustTierBadge tier] (▸ visible tier: wrapped in [Pressable] → opens
│ [TrustTierInfoSheet], U-07c 2026-09-17), facts=[GlyphFactRow] 🥇 ELO tier → club-avatar chip
│ (▸ primaryClubId) → 🎾 tennis career years (▸ tennisStartYear)
└─ [FlatColumn]
├─ lead (one hero-zone block, no divider inside):
│ ├─ ELO hero fact — [EloFactRow] (shared with the public profile) → opens [ProfileTierInfoSheet] via useTierInfoSheet
│ │ (currentElo, matchesPlayed) — "ELO" label + big value + [ProgressBar] to next tier
│ └─ stats: [Well]+[EmptyState] "아직 경기가 없어요" (no matches) ▸ else [StatStrip]
│ (4 cells: 총 경기/승률/연속 승리/이번 달, or 5-cell 3-column grid adding 출석률
│ when globalAttendanceRate is set)
├─ ▸ [FriendsSummaryCard]
├─ ▸ [AchievementsPreviewSection]
├─ ▸ [AttendanceSection] (own profile only)
├─ ▸ [MannerTagsSection]
└─ [ProfileMenuCard] — RowList settings menu; 공유 row (▸ profile loaded) + 로그아웃 rowStates.
- Unified loading:
SkeletonFlatPage kind="type"untilplayer.queries,mannerTagsQ,achievementsQ,attendanceQ,clubQall settle. - No matches yet →
Well/EmptyStateinstead of the stat grid; matches with no attendance data → 4-cellStatStrip; matches + attendance → 5-cellStatStrip(sharedcolumns={3}grid, fixed 2026-09-08 so row 2 aligns under row 1). - Club fact chip shown only when
primaryClubIdset; tennis-career fact only whentennisStartYearset; next-tier progress line only when a next tier exists. - Total-matches stat caps at
MATCH_HISTORY_LIMIT(50) and displays"50+"past the cap rather than implying a true lifetime count. - Pull-to-refresh (
usePullToRefresh(player.retry)).
Actions.
- DM inbox icon →
routes.dmInbox(shows unread dot whendmUnreadCount > 0). - Settings gear →
routes.settings. - ELO hero-fact row (
EloFactRow) tap → opensProfileTierInfoSheet(useTierInfoSheet, shared with the public profile). - Title badge tap (only rendered when the tier is visible, i.e. not sprout) → opens
TrustTierInfoSheet(useTrustTierInfoSheet). ProfileMenuCard공유 row (shown once profile loaded) →handleShareStats(useShareStatCard).ProfileMenuCard로그아웃 row →useConfirmdialog →signOut().- Pull to refresh.
Gaps. none found.
Profile Share Receiver
- Route:
/p/[userId] - Screen:
ProfileShare—apps/mobile/app/p/[userId].tsx - Shell: none (bare route file, no
MainTabShell/DetailShell) - Design: unreviewed
- Verified: 2026-09-11
Purpose. Universal-link receiver for a profile share URL (twomore://p/<userId> / https://twomore.app/p/<userId>) — resolves the id then redirects to the canonical profile route.
Anatomy. Three exclusive branches, no shell:
├─ loading → [YStack] + [SkeletonHero]
├─ not found → [YStack centered] + [Text] t().share.profileNotFound
└─ resolved → [Redirect] to routes.profileDetail(profile.id)States. loading (useProfile fetching) · not-found (profile is null after fetch) · resolved (immediate Redirect, nothing rendered).
- Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, viaQueryBoundary— never the empty state (2026-09-11 offline sweep).
Actions. None — pass-through redirector only; unauthenticated-safe since useProfile here needs no session.
Gaps. none found.
Public Profile
- Route:
/profile/[userId] - Screen:
PublicProfileScreen—packages/features/profile/src/public-profile-screen.tsx - Shell: DetailShell · title
displayName(orstrings.notFound) · ground=canvas· headerRight: DMHeaderIconButton(▸ not self + authed) - Design: ratified
- Verified: 2026-09-17
Purpose. Read-only view of another user's profile — identity, ELO/stats, play preference, and a friendship-action band.
Anatomy.
├─ [DetailShell] scroll=false footer=[BottomCtaBand] ▸ showFriendshipBand
├─ [QueryBoundary] loading=[SkeletonFlatPage kind="type"]
└─ ▸ profile resolved:
├─ [TypeHero] leading=[AvatarBubble HERO], title=displayName, NO titleBadge,
│ facts=[GlyphFactRow] 🥇 ELO tier → 📍 region+district (one merged fact) → 📊 reliability
└─ [FlatColumn]
├─ lead: ELO hero fact (label+value, no progress bar) + [StatStrip] 총 세션·출석률·
│ 신뢰 등급 (valueSlot=[TrustTierBadge size=sm]; cell tap → opens [TrustTierInfoSheet],
│ U-07c 2026-09-17)
├─ [SectionBlock] title="플레이" → [FactList] 형식 (▸ |singlesElo−doublesElo|>50) · 승률
└─ ▸ [Well] tone="primary" 친구 beat — only when isFriendMissing record: [NotFoundState] renders instead of the tree above once QueryBoundary settles with no profile (2026-09-16 converted from a bespoke Trophy EmptyState).
States.
- Own profile uses
useProfile; any other id usesusePublicProfile(maskedget_public_profile_viewSECDEF — hidden fields resolvenulland their facts/rows drop naturally). FriendshipButton: none (친구 추가) / outgoing-pending (요청 보냄→ confirm-cancel) / incoming-pending (수락/거절 pair) / accepted (친구→ confirm-remove) / blocked (returnsnull, band hidden entirely viashowFriendshipBandgate).- Format-preference fact only when
|singlesElo − doublesElo| > 50; 친구 beat only whenisFriend.
Actions.
- DM message icon →
useInitiateDmThread→routes.dmThread(threadId). - 신뢐 등급 stat cell tap → opens
TrustTierInfoSheet(useTrustTierInfoSheet). 친구 추가→useSendFriendRequest.- Outgoing
요청 보냄tap → confirm →useDeclineFriendRequest(cancel). - Incoming 수락/거절 →
useAcceptFriendRequest/useDeclineFriendRequest. - Accepted
친구tap → confirm →useRemoveFriend. - Back chevron.
Gaps. none found.
Achievements Gallery (업적)
- Route:
/profile/achievements - Screen:
AchievementsGalleryScreen—packages/features/profile/src/achievements-gallery-screen.tsx - Shell: DetailShell · title
t().achievements.title(업적) · headerRight: none - Design: unreviewed
- Verified: 2026-09-13
Purpose. Full catalog browse of all ACHIEVEMENT_TYPES (grouped into 성장/출석/교류/탐험/승부/히든), showing unlocked achievements with full meta and locked ones greyed with a hint or progress.
Anatomy.
├─ [DetailShell] title=업적 back
├─ isLoading → [SkeletonCard]×2 (own early-return DetailShell)
├─ isError → [QueryErrorState] (own early-return DetailShell)
└─ [FlatColumn]
├─ lead: [StatStrip] 달성(achieved) / 전체(all) counts
└─ per DISPLAY_GROUP_ORDER group → [SectionBlock] title=group label
└─ one lifted [Card] (explicit Canvas-law carve-out — a browsing collection,
not chrome) containing N rows, [Divider inset="card"] between:
icon bubble + name/description/hint + ▸[ProgressBar]+current/target
(locked quantitative types only) + [AchievementStateBadge] unlocked/comingSoon/lockedStates.
- Loading (
SkeletonCard×2) / error (QueryErrorState, retry) / loaded. - Unlocked → full-color icon, real name/description,
unlockedbadge. - Locked with a production unlock path (
hasProductionUnlockPath, quantitative types likeMATCHES_10) → greyed, real hint,ProgressBar+current/targetderived fromusePlayerStatsDetail. - Locked with NO production unlock path (most stat-based types — only seed-RPC-writable today) → greyed, generic "준비 중"
comingSoonbadge, no progress bar, no specific hint (2026-07-17 UX audit finding: a quantified promise the app can't yet deliver). - Hidden/secret locked achievement (
meta.hidden && !unlocked) → name replaced withstrings.hidden, description replaced with "탭하여 잠금해제". - Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, viaQueryBoundary— never the empty state (2026-09-11 offline sweep).
Actions. Back only — rows are not tappable; this is a pure browsing gallery.
Gaps.
- (parked by owner — "achievements later") ~54 of the catalog's types have no production unlock path until a stat-unlock engine ships; most of the gallery reads 준비 중 today.
Friends (친구 목록)
- Route:
/profile/friends - Screen:
FriendsScreen—packages/features/profile/src/friends-screen.tsx - Shell: DetailShell · title
t().friendship.friendsSection(친구 목록) · headerRight:UserPlusHeaderIconButton→/profile/friends/add - Design: unreviewed
- Verified: 2026-09-17
- **Notes:**2026-09-17: person/club name
ListRows passtitleLines={NAME_LINES}(U-13, two-line proper names)
Purpose. Full friends list — incoming/outgoing requests plus accepted friends, with accept/decline/cancel/remove actions.
Anatomy. GroupedFeedList (virtualized, COMP-5) with up to 3 sections, each omitted entirely when empty; renderItem dispatches on a kind discriminator, not section title:
├─ [DetailShell] scroll=false headerRight=[UserPlus icon]→/profile/friends/add
└─ [GroupedFeedList]
├─ ▸ "받은 친구 요청" section → [IncomingRow]×N (avatar, name, region sub, 거절/수락 buttons)
├─ ▸ "보낸 친구 요청" section → [OutgoingRow]×N (avatar, name, 대기 중 Badge, 요청 취소 button)
└─ ▸ "친구 (N)" section → [FriendRow]×N (avatar, name, "ELO N", region, [RemoveIconButton],
chevron) → tap → routes.profileDetail(userId)States.
- Loading (3×
SkeletonRow, all-fetched gate across 3 independent queries) / error (QueryErrorState, any of the 3 failing) / empty (all 3 lists empty → fullEmptyStateicon=Users, action="친구 찾기") / populated. RemoveIconButtononFriendRowcarries its own confirm — replaces a prior hiddenonLongPressgesture (design-harmony audit 2026-09-10 finding #3, per file header comment).- Pull-to-refresh refetches all three queries together.
- Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via an earlyOfflineEmptyStatereturn — never the empty state (2026-09-11 offline sweep).
Actions.
+header icon →routes.profileFriendsAdd.- 거절/수락 on an incoming row.
- 요청 취소 on an outgoing row (confirm dialog).
- Tap a friend row →
routes.profileDetail(userId)(withprefetchProfileononPressIn). RemoveIconButtonon a friend row → confirm → remove.- "친구 찾기" empty-state CTA →
routes.profileFriendsAdd. - Pull to refresh.
Gaps. none found.
Add Friend (친구 찾기)
- Route:
/profile/friends/add - Screen:
AddFriendScreen—packages/features/profile/src/add-friend-screen.tsx - Shell: DetailShell · title
t().friendship.findFriends(친구 찾기) · headerRight: none - Design: unreviewed
- Verified: 2026-09-17
- **Notes:**2026-09-17: person/club name
ListRows passtitleLines={NAME_LINES}(U-13, two-line proper names)
Purpose. Search other players by display name and send friend requests.
Anatomy. Rows rail via SCROLL_CONTENT.railedList (list-rows-own-rail, 2026-09-16) — no per-row paddingHorizontal wrapper.
├─ [DetailShell] scroll=false
├─ [SearchBar] value=query onChange=setQuery autoFocus (debounced via useDebouncedValue)
└─ [ScrollView]
└─ [RowList] → [SearchResultRow]×N — [AvatarBubble LG], name, sub="ELO N[, region]",
trailing per-row relationship controlStates.
- No query yet →
EmptyStateicon=UserSearch,variant="compact". - Fetching (debounced) → 5×
SkeletonRow. - Error →
QueryErrorStatewith retry. - No results →
EmptyStateicon=UserSearch, title=t().common.noResults. - Results →
RowList, viewer's own id filtered out client-side. - Per-row trailing state: already friends → static
친구text; pending (server or local-optimisticrequestedLocally) → disabled outline요청 보냄button; no relation → primary친구 신청button. - Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via an earlyOfflineEmptyStatereturn — never the empty state (2026-09-11 offline sweep).
Actions.
- Type in
SearchBar→ debounceduseProfileSearch(debouncedQuery, 20). 친구 신청→useSendFriendRequest; flips to요청 보냄immediately via local state (no wait for refetch).- Back.
Gaps. none found.
Practice Log (연습 기록)
- Route:
/profile/practice - Screen:
PracticeLogScreen—packages/features/profile/src/practice-log-screen.tsx - Shell: DetailShell · title
strings.pageTitle(연습 기록) · headerRight: none - Design: ratified
- Verified: 2026-09-16
Purpose. Solo/off-court practice tracker — log a quick practice entry (type + duration) and browse history newest-first with a running total.
Anatomy. Flat canvas migration (F4, 2026-09-07) — entries are bare ListRows, not boxed cards; the summary is a StatStrip used as the FeedList header, not a Card: The FeedList rails via SCROLL_CONTENT.railedList (list-rows-own-rail, 2026-09-16) — no per-row paddingHorizontal wrapper.
├─ [DetailShell] scroll=false
├─ body branch: isLoading→[SkeletonCard]×2 / isError→[QueryErrorState] /
│ empty→[EmptyState icon=Activity variant="full"] / else→
│ [FeedList] header=[StatStrip] (countLabel, totalLabel=durationValue(totalMin))
│ → [PracticeRow]×N (icon bubble, type label, date sub, duration [Badge])
├─ [BottomCtaBand] (sibling, always visible in both empty and populated states)
│ → [ActionButton] strings.addCta → opens AddPracticeSheet
└─ [AddPracticeSheet] ([ModalPanel]) — type [PillNav], duration [PillNav] (30/60/90/120),
취소/추가 [ActionButton]s → useCreatePracticeLogStates. loading (SkeletonCard×2) · error (QueryErrorState, retry) · empty (strings.empty/emptySubtitle) · populated (StatStrip header + FeedList of logs) · add-sheet open/closed with submitting-disabled buttons.
- Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, viaQueryBoundary— never the empty state (2026-09-11 offline sweep).
Actions.
BottomCtaBandCTA (always visible) → opensAddPracticeSheet.- In sheet: pick type via
PillNav(7TYPE_CHOICES), pick duration viaPillNav(DURATION_CHOICES), 취소/추가 submit →useCreatePracticeLog. - Back.
Gaps. none found.