Skip to content

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: ProfileScreenpackages/features/profile/src/profile-screen.tsx
  • Shell: MainTabShell · title t().profileScreen.pageTitle (프로필) · ground=canvas · headerRight: DM inbox HeaderIconButton (unread dot) + Settings HeaderIconButton
  • 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) + 로그아웃 row

States.

  • Unified loading: SkeletonFlatPage kind="type" until player.queries, mannerTagsQ, achievementsQ, attendanceQ, clubQ all settle.
  • No matches yet → Well/EmptyState instead of the stat grid; matches with no attendance data → 4-cell StatStrip; matches + attendance → 5-cell StatStrip (shared columns={3} grid, fixed 2026-09-08 so row 2 aligns under row 1).
  • Club fact chip shown only when primaryClubId set; tennis-career fact only when tennisStartYear set; 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 when dmUnreadCount > 0).
  • Settings gear → routes.settings.
  • ELO hero-fact row (EloFactRow) tap → opens ProfileTierInfoSheet (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 → useConfirm dialog → signOut().
  • Pull to refresh.

Gaps. none found.


Profile Share Receiver

  • Route: /p/[userId]
  • Screen: ProfileShareapps/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) → OfflineEmptyState with retry, via QueryBoundary — 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: PublicProfileScreenpackages/features/profile/src/public-profile-screen.tsx
  • Shell: DetailShell · title displayName (or strings.notFound) · ground=canvas · headerRight: DM HeaderIconButton (▸ 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 isFriend

Missing 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 uses usePublicProfile (masked get_public_profile_view SECDEF — hidden fields resolve null and their facts/rows drop naturally).
  • FriendshipButton: none (친구 추가) / outgoing-pending (요청 보냄 → confirm-cancel) / incoming-pending (수락/거절 pair) / accepted (친구 → confirm-remove) / blocked (returns null, band hidden entirely via showFriendshipBand gate).
  • Format-preference fact only when |singlesElo − doublesElo| > 50; 친구 beat only when isFriend.

Actions.

  • DM message icon → useInitiateDmThreadroutes.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.


  • Route: /profile/achievements
  • Screen: AchievementsGalleryScreenpackages/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/locked

States.

  • Loading (SkeletonCard×2) / error (QueryErrorState, retry) / loaded.
  • Unlocked → full-color icon, real name/description, unlocked badge.
  • Locked with a production unlock path (hasProductionUnlockPath, quantitative types like MATCHES_10) → greyed, real hint, ProgressBar + current/target derived from usePlayerStatsDetail.
  • Locked with NO production unlock path (most stat-based types — only seed-RPC-writable today) → greyed, generic "준비 중" comingSoon badge, 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 with strings.hidden, description replaced with "탭하여 잠금해제".
  • Offline cold start (query paused, nothing fetched) → OfflineEmptyState with retry, via QueryBoundary — 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: FriendsScreenpackages/features/profile/src/friends-screen.tsx
  • Shell: DetailShell · title t().friendship.friendsSection (친구 목록) · headerRight: UserPlus HeaderIconButton/profile/friends/add
  • Design: unreviewed
  • Verified: 2026-09-17
  • **Notes:**2026-09-17: person/club name ListRows pass titleLines={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 → full EmptyState icon=Users, action="친구 찾기") / populated.
  • RemoveIconButton on FriendRow carries its own confirm — replaces a prior hidden onLongPress gesture (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) → OfflineEmptyState with retry, via an early OfflineEmptyState return — 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) (with prefetchProfile on onPressIn).
  • RemoveIconButton on a friend row → confirm → remove.
  • "친구 찾기" empty-state CTA → routes.profileFriendsAdd.
  • Pull to refresh.

Gaps. none found.


Add Friend (친구 찾기)

  • Route: /profile/friends/add
  • Screen: AddFriendScreenpackages/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 pass titleLines={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 control

States.

  • No query yet → EmptyState icon=UserSearch, variant="compact".
  • Fetching (debounced) → 5× SkeletonRow.
  • Error → QueryErrorState with retry.
  • No results → EmptyState icon=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-optimistic requestedLocally) → disabled outline 요청 보냄 button; no relation → primary 친구 신청 button.
  • 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.

  • Type in SearchBar → debounced useProfileSearch(debouncedQuery, 20).
  • 친구 신청useSendFriendRequest; flips to 요청 보냄 immediately via local state (no wait for refetch).
  • Back.

Gaps. none found.


Practice Log (연습 기록)

  • Route: /profile/practice
  • Screen: PracticeLogScreenpackages/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 → useCreatePracticeLog

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

Actions.

  • BottomCtaBand CTA (always visible) → opens AddPracticeSheet.
  • In sheet: pick type via PillNav (7 TYPE_CHOICES), pick duration via PillNav (DURATION_CHOICES), 취소/추가 submit → useCreatePracticeLog.
  • Back.

Gaps. none found.

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