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: ProfileScreen — packages/features/profile/src/profile-screen.tsx (zones in profile-tab/)
  • Shell: MainTabShell · title t().common.profile (프로필) · canvas · always the reading column (the width prop is gone) · actions: the gear (설정) alone, coveredByWebNav. No 메시지 glyph: the inbox is reached from 홈's header (L3-14).
  • Design: unreviewed
  • Verified: 2026-11-15
  • Batch 58 (ST3, 2026-11-13), States: The identity zone decides the page through pageState: loading (identity + friends bars), cold offline, error or a missing profile (what 프로필, one retry); 친구 and 업적 fail alone as zones.
  • Notes: 2026-11-10 (W4 batch 15, review 39/50/52): rebuilt to the hub board. The ELO row, the stat Strip, the trust-tier badge, the manner tags, the attendance section, the settings/share/logout menu card and the DM header action are gone from this page (matches are 기록's, the account is 설정's, 내 성적표 공유 is 기록's header; the 4-tier explainer gave way to 신뢰 기록's own). The closing Cell group holds 테니스 여정 (door #88, flat-v2-hub.jsx), drawn once the identity reads are ready (L3-08).

Purpose. Who the viewer is to other players: the identity Block, the 친구 faces and the 업적 Shelf. A statement with one door (신뢰).

Anatomy. One scroll column; the identity zone's reads gate the page, 친구 and 업적 carry their own network state.

├─ [MainTabShell] title=프로필 actions=[gear]
└─ [ShellScrollView]  (pull to refresh, 8 under the header)
   ├─ identity zone  (profile, useMyClubs, useClubMembers of the primary club, useTrustRecord)
   │  ├─ [Block kind="identity"] ← identityBlockModel (@twomore/app):
   │  │    label = the one spoken stop · name · Level tag (ntrpLevelNumeral) · facts "club | 구력"
   │  │    (an official's 총무/대표 after the club, 외 N곳 from the viewer's own clubs; while the rating is
   │  │    not settled its basis in the 구력's place: NTRP 자가 평가 / NTRP 임시) ·
   │  │    trust door "신뢰 72점" or "신뢰 3/5회" → /profile/trust
   │  └─ ▸ at the RSVP limit: [Notice meaning=attention, no action] "불참 N건으로 한도에 닿았어요. {날짜}에 풀려요"
   ├─ 친구 zone  (useFriendsSummary, 6)
   │  ├─ ▸ friends: [SectionHead first 친구 · N명 · 전체 보기 ›] + [AvatarRow] 6 faces (each → that 공개 프로필) + "+N" disc (no press)
   │  └─ ▸ none: [SectionHead first 친구] + [EmptyState first compact glyph=users, action 친구 찾기]
   └─ 업적 zone  (useAchievements)
      └─ ▸ any earned: [Shelf preset="badge"] the five newest, head 업적 · 6/20 · 전체 보기 ›
   └─ closing [CellGroup] → [Cell variant="nav"] 테니스 여정 → /profile/journey   (24 under the Shelf; drawn with the ready page only)

States.

  • Offline (F1c, the app's root strip says it): unchanged rule: a cached page shows offline under the root strip and only its stale test is named. (prof-stale)

  • Loading (the identity reads pending): the Block as bars, the 친구 head with six circle bars; no Shelf bar (board prof-loading).

  • Error: EmptyState kind=error what=프로필 with 다시 시도; also a profile that does not exist. Cold offline: EmptyState kind=offline. Offline with a cache (prof-stale): the page unchanged, the root offline strip says it.

  • 친구 or 업적 failing alone: a danger Notice with 다시 시도 in the zone's place; the rest stands. 업적 loading after the page is ready: the Shelf's bars.

  • Day one (prof-dayone): no club and no match claim no rating (no Level tag), the Block says 아직 가입한 클럽이 없어요; the door reads 신뢰 0/5회; 친구 is the page's one prompt; no 업적 (EMPTY-ONCE).

  • Early (prof-early): the NTRP the player chose reads 자가 평가; provisional (prof-prov): NTRP 임시. Under five attended sessions there is no score: the door reads the steps.

  • The 한도 Notice needs the record's rsvpBlockStrikes (the server's cap, a policy value) and counts the record's rows with a strike inside the window; it lifts on the dropsOffOn of the row whose leaving brings the count under the cap (trust-limit-notice.ts).

  • Fewer friends than six: the faces and no disc; 24 friends: six faces and +18.

  • 2026-11-13 (UX-7, the state helper): The identity zone decides the page through pageState: loading (identity + friends bars), cold offline, error or a missing profile (what 프로필, one retry); 친구 and 업적 fail alone as zones.

Actions.

  • Gear → routes.settings.
  • The 신뢰 door → routes.profileTrust() (신뢰 기록).
  • 친구 전체 보기 › → routes.profileFriends; a face → routes.userProfile(friendId); the +N disc presses nothing.
  • 친구 찾기 (day one) → routes.profileFriendsAdd.
  • 업적 전체 보기 › or a badge → routes.achievements.
  • The closing Cell 테니스 여정 → routes.profileJourney.
  • Pull to refresh refetches all three zones.

Wide windows (web). MainTabShell width="reading" (680 column on wide windows); the 설정 glyph is coveredByWebNav. Phones unchanged.

Gaps. Nothing links the viewer to their own 공개 프로필 (the forms board puts the preview in 공개 범위 설정). The level-up celebration is the LevelUpDialog (F1b): it opens from the scorecard and from 기록's Notice, never from this page.


Profile Share Receiver ​

  • Route: /p/[userId]
  • Screen: ProfileShareReceiverScreen — packages/features/sessions/src/share-receivers/link-receiver-screens.tsx (route file apps/mobile/app/p/[userId].tsx only passes the param)
  • Shell: DetailShell · title empty (back-only Header)
  • Design: unreviewed
  • Verified: 2026-11-11

Purpose. Universal-link receiver for a profile share URL: resolves the id and replaces to the profile. It shows no preview (the rule: a preview never says who) and takes the shared loading / not-found / offline / error frame of the four share links.

Anatomy.

├─ Header (back, no title)
└─ one of
   ├─ loading → Hero and four Cell skeletons
   ├─ not found → [EmptyState notfound] t().share.profileNotFound · 홈으로 가기
   ├─ offline → [EmptyState offline] + retry
   ├─ error → [EmptyState error] naming 프로필 + retry
   └─ resolved → replace routes.profileDetail(profile.id)

States. loading · resolved (replace, skeleton until it lands) · not found · offline cold start (never not found) · error.

Actions.

  • back (Header)
  • retry (offline / error); 홈으로 가기 (not found)

Gaps. none found; nothing ran on a device.



Public Profile (공개 프로필, L2-06) ​

  • Route: /profile/[userId]
  • Screen: PublicProfileScreen — packages/features/profile/src/public-profile-screen.tsx (pieces in public-profile/)
  • Shell: DetailShell · title t().common.profile (프로필, a constant; the Block's name is the page's h1) · actions: share (testID publicProfile.shareButton) and the ⋯ menu, once the person is read; the plain states keep the back arrow alone. The band is the shell's footer.
  • Design: unreviewed
  • Verified: 2026-11-15
  • Club door: the 클럽 row is followed by the section seam (CLUB_DOOR_TAIL), so 전적's head does not touch it.
  • Batch 58 (ST3, 2026-11-13), States: pageState over the person's reads, PageStateView: loading (the Block and every Module as bars), cold offline, error (what 프로필, one retry), person gone (title + body, door 홈으로 가기, 돌아가기 secondary).
  • Notes: 2026-11-10 (W4 batch 15, review 50/52): rebuilt to the detail board (DtlPp*). The TypeHero with its tier badge, the ELO fact row, the 4-tier and ELO explainer Sheets, 경기 기록 rows, 소속 클럽 row, 받은 매너 태그, the 업적 preview section, FriendshipBand and the stat-card share are gone from this page (the 4-tier explainer gave way to 신뢰 기록's own; the stat-card image stays on 기록). Another person shows their primary club only (P3). 2026-11-10 (W4 batch 21a, R9): 전적's 전체 보기 (month, as theirs) and 나와의 전적's 자세히 are open. 2026-11-11 (F1a, owner decision 1): the club in the Block is a plain fact again (21a's link is reverted in the kit); a separate nav Cell 클럽 under the Block is the door to 클럽 홈. This supersedes review 51's "no club Cell under the Block" for this one door.

Purpose. Another player's page, an object page: who they are (the Block), what kind of player they are (전적, 플레이, 최근 경기, 나와의 전적, 업적), and the friendship as the one band.

Anatomy. One status for the page (usePublicProfilePage): any read failing or waiting offline holds the whole page in its plain state.

├─ [DetailShell] title=프로필 actions=[share, ⋯] footer=[CtaBand ▸ band]
└─ ▸ ready: (8) [Block kind="identity"] ← identityBlockModel (the same builder as 프로필): face, name + Level tag,
   │  club + 구력 (or NTRP 자가 평가 / 임시 in the 구력's place) as plain text, door 신뢰 84점 / 신뢰 3/5회 → 신뢰 기록 of this person
   ├─ ▸ a club the viewer reads, not the viewer's own page: (8) [CellGroup > Cell variant="nav"] 클럽 · {club name} → 클럽 홈 (ClubDoor, testID publicProfile.clubDoor)
   ├─ ▸ day one: one calm line 아직 기록한 경기가 없어요 (no Module)
   └─ else, by modulesOf / showsOf (page-model.ts):
      ├─ [SectionHead first 전적 · door 전체 보기 ›] + [Strip tiles] 경쟁 경기 · 경쟁 승률 · ELO (ELO alone when the results are private; the page reads `usePlayerResults(userId, { limit: 5, ratedOnly: true })`, so the figures count 경쟁 matches only, even on one's own public profile, batch 66); the door only when `access` is detail
      ├─ ▸ not a stranger: [SectionHead 플레이] + [Strip tiles] 단식 ELO · 복식 ELO (a format never played has no tile) + static Cells 주 종목 · 선호 시간
      ├─ ▸ access detail + rows: [Shelf compact] 최근 경기 · N경기, five cards from the person's side, no door; a card → /sessions/{id}/scorecard
      ├─ ▸ a club-mate with a score: TogetherZone: [SectionHead 나와의 전적 · door 자세히] + [Strip tiles] 같은 편 · 상대 (the viewer's own history; draws nothing, door included, when the two never played)
      └─ ▸ a score and badges: [Shelf badge] 업적 · 5/20, no door, nothing pressed

Missing person: EmptyState kind=notfound (프로필을 찾을 수 없어요 / 탈퇴했거나 볼 수 없는 프로필이에요), the bare back-arrow header and the door 홈으로 가기.

States.

  • Write failures (ruling EP-3): 친구 끊기 keeps its Dialog open with the line as the body; 메시지 and a refused 차단 are told in a Dialog (dm_unavailable has its own line); friend requests keep the toast of their queueable hooks (their failure can land after the person left).

  • Reads: useProfile for the viewer's own id, else usePublicProfile (masked; the face only where can_view_face allows, 구력 public since 00724); the primary club (useClub: data draws the 클럽 row; null, a private club the viewer may not read, and no club draw no row; while loading the page is the loading frame); useTrustRecord; usePlayerResults, usePlayerPlayFacts, usePlayerAchievements (migration 00704; the server decides access and a withheld fact is null).

  • Audience (page-model.ts): a stranger sees the Block, 전적 and 업적 only; access none shows ELO alone, no 최근 경기 and no 선호 시간; under five sessions (no score) there is no 나와의 전적 and no 업적 and the door reads the steps; day one is the calm line.

  • Band by friendship (friend-model.ts, band-props.ts): none → 친구 신청 · request sent → tonal 신청 취소 (asks a Dialog) · request received → 거절 + 수락 · friends → 메시지 보내기 · a person the viewer blocked (useBlockedUsers, P10) → no band · yourself → no band. The band is inert while the friendship reads and gone if that read fails.

  • ⋯ by friendship: not a friend → 메시지 보내기 (a message request), then 신고 + 차단 in one danger group; a friend → 친구 끊기 + 신고 + 차단 (the band has 메시지 보내기); blocked or unknown → 신고 alone; yourself → no ⋯.

  • Loading: the Block and every Module under its head as bars. Error / cold offline: EmptyState with the retry. 나와의 전적 loading or failing is its own zone's (bars / a danger Notice with 다시 시도).

  • Your own id (P7): the same page without the band, the friendship rows, 나와의 전적 and the 클럽 row. There is no redirect on /profile/[userId]: the page sets isSelf and the body passes no club for the viewer's own id.

  • 2026-11-13 (UX-7, the state helper): pageState over the person's reads, PageStateView: loading (the Block and every Module as bars), cold offline, error (what 프로필, one retry), person gone (title + body, door 프로필 → the profile tab, 돌아가기 secondary).

Actions.

  • 공유 → useShareProfile (the link routes.publicProfileShare(userId) through the OS sheet).
  • The 신뢰 door → routes.profileTrust(userId). The 클럽 row → routes.club(clubId) (the primary club only; drawn only when useClub returned it, and not on the viewer's own page). The Block's one control is the 신뢰 door. 전체 보기 (access detail only) → routes.record('month', getMonthRange(now), { userId }), 기간 기록 read as theirs. 자세히 → routes.analysisRivalsWith(userId), 선수별 전적 with them (the viewer's own matches; no permission of the person). A 최근 경기 card → routes.scorecard(sessionId) (the scorecard page reads the session with RLS and says 찾을 수 없어요 with 경기 보기 when the viewer may not see it).
  • 친구 신청 → useSendFriendRequest; 신청 취소 → Dialog → useDeclineFriendRequest; 수락 → useAcceptFriendRequest; 거절 → useDeclineFriendRequest; 메시지 보내기 → useInitiateDmThread → routes.dmThread.
  • ⋯: 친구 끊기 → Dialog naming the person → useRemoveFriend; 차단 → Dialog naming the person → useBlockUser({ userId }) through mutateAsync + refusalOf (the page stays; the band goes; the server also deletes the pair's friendship, silently, migration 00746); 신고 → ReportModal (a person report: no subject). The Dialog's body follows the friendship (B3): friends read 서로 프로필을 볼 수 없고, 친구도 끊겨요 (pp-block), anyone else (a pending request too) 서로 프로필을 볼 수 없고 메시지도 주고받을 수 없어요 (pp-block-solo), both from publicProfilePage.blockBody(friends). A refused block replaces the body with 차단하지 못했어요. and the Dialog stays open (closing it clears the line); an untyped failure propagates. After a block useBlockUser invalidates friendshipKeys.all, the 순위표 and the profile reads. The legacy blocked friendship status no longer exists (FriendshipStatus is pending | accepted; the mapper parses it with friendshipStatusSchema, so an unknown status throws a DomainError).
  • Back.

Gaps. The 소속 클럽 Sheet (board pp-clubs) and 외 N곳 are not built: no read lists another person's clubs (P3), so only the primary club has a door and pp-clubs has no screen test. The 자세히 head door reads 자세히 보기 (common.showDetails) where the board says 자세히, until a publicProfilePage word is added. ReportModal's submit reads 신고 제출 where the board says 신고하기, and its optional detail has a placeholder where the board has a 자세한 내용 label. Nothing links the viewer to their own 공개 프로필.


  • Route: /profile/achievements
  • Screen: AchievementsGalleryScreen — packages/features/profile/src/achievements-gallery-screen.tsx (pieces in achievements/)
  • Shell: DetailShell · title t().common.achievements
  • Design: unreviewed
  • Verified: 2026-11-15
  • Glyphs: every catalog entry resolves to a glyph through ACHIEVEMENT_ICON_RULES (never the fallback, never a ring); 첫 경기 is people, 10경기 a trophy, 50경기 a medal, as the board draws them.

Purpose. Browse what a player can earn today plus anything already held; earned ones in colour. Rebuilt to list-b#ac (L3-09, W4 batch 20).

Anatomy.

├─ [DetailShell] title=업적
└─ [QueryBoundary] queries=[achievements]
   ├─ progress line "N개 중 M개 달성" · "남은 업적 K개"
   └─ per display group → [SectionHead] count earned/total → [MediaRow achievement Thumb]×N (name, description or hint, trailing progress)

States. loading SkeletonKind media×6 · error and offline through the boundary. Row states: earned (solid tier fill), locked (gray, progress as the trailing figure of a quantitative type), hidden (a ? and 숨겨진 업적 alone). Only what can be earned today is listed (isAchievementShown). The spoken name carries the tier and the state.

  • Offline (F1c, the app's root strip says it): unchanged rule: a cached page shows offline under the root strip and only its stale test is named. (ac-stale)

Actions. None: no row opens anything. Back.

Gaps. Offline with a cached gallery the page shows it under the root strip (owner decision 16, 2026-11-10); with nothing cached it is the offline state.


Tennis Journey (테니스 여정, L3-08) ​

  • Route: /profile/journey
  • Screen: JourneyScreen — packages/features/profile/src/journey/journey-screen.tsx (pieces in journey/)
  • Shell: DetailShell scroll=false · title t().profileJourneyPage.title (테니스 여정) · no header action, no FAB, no band
  • Design: unreviewed
  • Verified: 2026-11-11

Purpose. The player's own match notes, newest day first under Day headers (flat-v2-list-b#journey), reached from the closing Cell of 프로필 (door #88). A note row opens that match's result (L3-38). A note is private to its author; a note is written where its match ends, never here.

Anatomy. One growable list; the month head and Strip are its header.

├─ [DetailShell] scroll=false · title=테니스 여정
└─ [FeedList] (useJourneyList ← useMatchNotes(userId): pages of whole days)
   ├─ header: 12 · [SectionHead first] 이번 달 (no door) · [Strip face=tiles] 경기 | 노트  ← pages[0].month
   ├─ [DayHeader] ← dayHeaderOf(matchDay, today): 9월 18일 (금) · 오늘 / 어제
   ├─ [TextRow] l1 matchNoteTitle(note) · l2 venue · extra the note (2 lines, whole from 1.5x text)
   │            · trailing mood glyph (MATCH_NOTE_MOOD_GLYPH, 20, secondary) · name "match, venue, mood word, note" → routes.scorecard(sessionId)
   └─ footer: loadingMoreNode (three skeleton Text rows) while the next page reads

States.

  • Offline (F1c, the app's root strip says it): unchanged rule: a cached page shows offline under the root strip and only its stale test is named. (jy-stale)

  • Loading: the head, a skeleton Strip, five skeleton Text rows (jy-loading); no empty state.

  • First (jy-first): the page has no note: EmptyState first glyph=pencil 아직 남긴 노트가 없어요 / 경기가 끝나면 노트를 남겨 보세요, no head, no door.

  • Error (jy-error): QueryErrorState what=노트 with one retry. Cold offline (jy-cold): OfflineEmptyState, never "no notes". Offline with cached notes (jy-stale): the list unchanged under the app's offline strip, the rows still open; the next page is not read while offline.

  • A failed later page, or a refetch error with notes on the page, leaves the notes readable (no error state replaces them).

  • Text size (jy-200): the note is clamped to two lines below 1.5x text and runs whole from 1.5x (noteLines); the kit rows own the rest.

  • Paging: notes page by whole days (list_match_notes p_before_day); onEndReached reads the next page while hasNextPage, online, and never twice at once. The Strip's figures come with the first page.

Actions.

  • A row → routes.scorecard(note.sessionId) (the result, where the note's glyph and 노트 남기기 › edit the note).
  • Back → 프로필. Pull to refresh refetches every loaded page.
  • No write, no mutation, no head action.

Gaps. AI 요약 (the head's door, its reading page and the plan Sheet: board frames jy-ai, jy-free) is not built (owner, 2026-11-10); the head carries no action. The Day header names only 오늘 and 어제 (dayHeaderOf); the board's "3일 전" relative words are not drawn. The board's closing 24 gap under the last row is the list's scroll tail (SCROLL_CONTENT.list). Nothing ran on a device: the page is covered by jest with the kit mocks only (no real FeedList paging, no 200% render).


Friends (친구 목록, L3-18) ​

  • Route: /profile/friends
  • Screen: FriendsScreen — packages/features/profile/src/friends-screen.tsx (pieces in friends/)
  • Shell: DetailShell scroll=false · title t().friendship.friendsSection (친구 목록) · no header action; the FAB 친구 (spoken 친구 찾기) → /profile/friends/add
  • Design: unreviewed
  • Verified: 2026-11-15
  • Top: the list opens with a 12 header and its first section head is first, so the first Module sits 12 under the Header as 참가비 내역's does.
  • Lists and regions: answering a request refreshes the friends list, its summary, both request lists and the pair lookups together (a query key made with fewer arguments is the prefix of the longer one), and every row writes a person's region through the row model's region fact ("서울", never the stored key).
  • Batch 58 (ST3, 2026-11-13), States: friendsGate = pageState over the three reads, PageStateView: loading (seven person rows), cold offline (never first run), one error with one retry for all three (an error wins over a read still loading), first-run, list.

Purpose. The people the viewer is friends with, and the requests waiting on either side. Rebuilt to list-b#fr (L3-18, W4 batch 20).

Anatomy.

├─ [DetailShell] scroll=false · title=친구 목록 · no header action
└─ [YStack] → [GroupedFeedList]
   ├─ 친구 신청 → [PersonRow context=requests]×≤3 (received first, then sent; head shows "N건" past three) → [ExpandToggle] N개 더 보기
   └─ 친구 → [PersonRow context=friends]×N (region only)
   [Fab] 친구 (spoken 친구 찾기) · [Dialog] 신청 취소

States. loading SkeletonKind person×7 · error QueryErrorState with one retry · offline cold OfflineEmptyState (never the first run) · first run EmptyState first with no button (the FAB is the action) · a bucket with nothing draws no head · cached lists stay while offline.

  • Offline (F1c, the app's root strip says it): the cached lists stay under the root strip (fr-stale); a received request's 수락 and 거절 queue durably and stay live; rows that only navigate stay live.
  • 2026-11-13 (UX-7, the state helper): friendsGate = pageState over the three reads, PageStateView: loading (seven person rows), cold offline (never first run), one error with one retry for all three (an error wins over a read still loading), first-run, list.

Actions. Every row, request rows included, opens 공개 프로필. A received request answers in the row (수락 / 거절). A sent request has 신청 취소, which asks a Dialog (닫기 / 신청 취소, danger) before it withdraws. 더 보기 opens the rest in place. The FAB → /profile/friends/add. Pull to refresh.

Gaps. The list has no remove action: 친구 끊기 lives once, in 공개 프로필's ⋯.


Add Friend (친구 찾기) ​

  • Route: /profile/friends/add
  • Screen: AddFriendScreen — packages/features/profile/src/add-friend-screen.tsx (parts in add-friend/)
  • Shell: DetailShell · title t().friendship.findFriends (친구 찾기) · scroll=false · no actions
  • Design: unreviewed
  • Verified: 2026-11-15
  • Region: a result row's second line is the region fact, written by the row model in words ("서울"), never the stored key.
  • Batch 58 (ST3, 2026-11-13), States: searchPhase uses pageState for offline/error/searching: a typed search term is a hidden read, so offline is the offline state even for a term already searched; idle, none, results unchanged.

Purpose. Search people by name and send a friend request from the row. Built W4 batch 5 to flat-v2-list-b#af-ideal (L3-19).

Now. SearchField noCancel autoFocus over a tail-only ScrollView. Rows are the app PersonRow context="search" (line 2 the region only, no ELO, no Level) and open 공개 프로필. Relation comes from one pure map (add-friend/friend-relations.ts) over useFriends, useIncomingFriendRequests, useOutgoingFriendRequests and the session's local sends (no per-row useFriendship, Protocol A). The phase is add-friend/search-phase.ts.

Anatomy.

├─ [DetailShell] scroll=false
├─ [RailPad] → [SearchField] noCancel autoFocus
└─ [ScrollView] (SCROLL_CONTENT.list)
   └─ phase: idle | searching | offline | error | none | results
      results → [SearchResultRow]×N (PersonRow search, tag 친구 / 신청 보냄 / 신청 받음, or control 친구 신청)

States. idle EmptyState filtered (이름으로 친구를 찾아보세요 · 이름을 입력하면 바로 찾아요) · searching SkeletonKind person×5 while the debounce runs or the first fetch runs · offline cold OfflineEmptyState · error QueryErrorState title 검색하지 못했어요, one retry · none EmptyState filtered (검색 결과가 없어요 · 이름을 다시 확인해 보세요) · results (the viewer left out). A polite announcement 검색 결과 N명 per result set. A blocked person never appears (migration 00713).

  • Search: two characters start a search (one character is the idle "type to search" state, nothing fetched); rate_limited_profile_search is the error state's title with retry kept. Write failures: friend requests keep the toast of their queueable hooks.
  • 2026-11-13 (UX-7, the state helper): searchPhase uses pageState for offline/error/searching: a typed search term is a hidden read, so offline is the offline state even for a term already searched; idle, none, results unchanged.

Actions. Row → 공개 프로필. 친구 신청 control (aria "{name} 친구 신청") flips the row to Tag 신청 보냄 at once and shows the Toast 친구 신청을 보냈어요; on a refusal the control returns; a block refusal (HINT friend_request_blocked) reads 친구 신청을 보낼 수 없어요, the rate limit as before.

Gaps. A received request (신청 받음) is answered on 친구 목록 or 알림, not here. The board draws no offline-cached frame for this page.


My Session Fees (참가비 내역, L3-16) ​

  • Route: /profile/fees
  • Screen: MyFeesScreen — packages/features/profile/src/my-fees/my-fees-screen.tsx (FeesList, FeeRow; the reads in use-my-fees.ts, the rows in session-fee-row-model.ts)
  • Shell: DetailShell · title myFees.pageTitle (참가비 내역) · no headerRight, no CTA band, no tab bar
  • Design: unreviewed
  • Verified: 2026-11-11

Purpose. A member's own session fees across clubs, newest first by month: what they paid, what they still owe and what is coming back; built W4 batch 10 to flat-v2-list-a#sf-ideal (L3-16).

Now. One GroupedFeedList (a plain SectionHead per month, pinned, over TextRows), read from useMySessionFees(userId) joined with useSessionsByIds (format, event type, location, day, status) and useClubsByIds (the club's name). A row is the place (a club session names its club, a pickup its venue; a session or club the viewer can no longer read says 일정), the day and the type sessionTitle writes (복식, 복식 매치, 혼합복식 대회), and the amount at the end. The one Tag follows line 1 only while the fee is open and comes from SessionPaymentStatusBadge: 미납 (reserved), 확인 대기 (submitted), 환불 대기 (refund pending). Waived and an acknowledged refund say 면제 and 환불 확인됨 where the amount would be; paid is silent. State precedence is the board's: refund pending, refund acknowledged, waived, paid, submitted, reserved. The rows follow refundState strictly, whatever the seat or the session: a refund open (a cancel with the window left, a removal, a member who left the club or was removed, or a cancelled event) is 환불 대기 on a session that still stands; 환불 확인됨 once the payer confirmed; a paid cancelled seat with no refund opened (a late cancel, or a row an old build left) is an ordinary paid row with its amount. A cancelled session never shows 미납 or 확인 대기 (isSessionPaymentOutstanding). The order is the session's day, not created_at; the year shows only for another year than the screen's opening (now is a parameter). The page does not use QueryBoundary: useCompositeNetwork reports the loading, failed and cold-offline legs, and cached rows stay readable offline under the app's own strip.

Anatomy.

[DetailShell] title=참가비 내역 onBack
└─ [GroupedFeedList] (no Panes, no band)
   ├─ [Gap 12] (16 over the skeleton)
   ├─ per month: [SectionHead title="9월" | "2025년 12월"] → [TextRow] × n
   │     l1 = place · l1Tag = [SessionPaymentStatusBadge] only while open
   │     l2 = [day, type] · trailing = amount | 면제 | 환불 확인됨
   ├─ loading: [TextRow loading] × 6
   ├─ empty: [EmptyState first glyph=card] 아직 낸 참가비가 없어요 (no button)
   ├─ failed: [QueryErrorState what=참가비 내역] · cold offline: [OfflineEmptyState]

States. loading (six Text row skeletons; also while the sessions read is out, so no row flashes a fallback place) · empty · failed (one retry for every read) · cold offline · offline with cached rows (readable) · rows (each fee state above; a fee whose session cannot be read is a plain row with its amount and nothing to open).

  • Offline (F1c, the app's root strip says it): unchanged rule: a cached page shows offline under the root strip and only its stale test is named. (sf-stale)

Actions. A row → 일정 상세 (routes.clubSession(clubId, sessionId)), where 입금했다고 알리기 and 환불받았어요 already live: this page asks for nothing. Pull to refresh refetches every read. Back → the opener.

Gaps. The doors are not built here: the 클럽 홈 회비 pane's closing Cell (L2-01, the hubs wave) and 프로필 are the way in. The read is the newest 500 fees (the adapter's bound) with no pager. useClubsByIds reports no state, so a club name can arrive a beat after its row. The English page title says "Event fees" (the board's "Session fees" fails the English house test), and the English amount reads the house form "10,000 KRW" and the day "9/19" where the board draws "₩10,000" and "Sep 19". A cached money, attendance or score list does not say when it was read: the kit has no as-of slot on a list page (metaWithAsOf exists only inside a Block's offline state), so it needs a drawn piece (docs/design/flat-v2-open-issues.md).


Trust Record (신뢰 기록, L3-01) ​

  • Route: /profile/trust (another person's record by a userId query parameter; an official of a club reads the member's record in it with a clubId query parameter too)
  • Screen: TrustRecordScreen — packages/features/profile/src/trust-record-screen.tsx (parts in trust-record/: trust-record-body.tsx, trust-meter.tsx, trust-record-rows.tsx, trust-record-sheets.tsx, trust-page-states.tsx; the officials' view in official-record-view.tsx, official-body.tsx, official-excuse.tsx; the reads in use-trust-record-page.ts and use-official-extras.ts; the pure model in trust-page-model.ts, trust-rows.ts, trust-cap.ts, official-model.ts, trust-words.ts, official-words.ts)
  • Shell: DetailShell · title trustRecord.pageTitleSelf (내 신뢰 기록) for oneself, pageTitleOther (신뢰 기록) with the person's name as its sub for another person; for an official membersPage.memberRecord (멤버 기록) with the club as its sub · back · no tab bar
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST3, 2026-11-13), States: useTrustRecordPage decides with pageState; PageStateView: loading (one skeleton per zone), cold offline, error, record missing (door 홈으로 가기). The officials' view (멤버 기록) is a hidden read: offline it is the offline state even with a warm copy; the own record stays cached.

Purpose. One person's 신뢰 점수 and the record behind it; built W4 batch 15 to flat-v2-detail#tr-ideal (L3-01). It absorbs L3-02's explainer: the head's 점수 안내 is the one place the score is explained, in place of the four-tier TrustTierInfoSheet. W4 batch 21b adds the officials' view (tr-m-*): an owner or admin reads one member's record in their club, from 출석 현황, 회비 관리's and 멤버 관리's person Sheets. Follow-up batch E (migration 00754) adds the 사유 인정 요청: oneself asks for an absence to be excused from the record's own Sheet (the shared ExcuseRequestSheet of @twomore/app, also the ask after a late cancel on 일정 상세), reads the request's state on the row and in the Sheet, and an official answers it on the row, with 10 minutes to undo. An answer changes the record at once; the member is told after the 10 minutes (the 알림 tick), never earlier. Nobody decides their own record, in a club or a 매치: the server refuses it (excuse_self) and the page draws no answer and no 사유 인정 on the viewer's own record.

Now. useTrustRecord(userId, clubId) (the server decides what the viewer may see, record.viewer; a hidden field is null and draws nothing) joined with useClubsByIds (line 1 of a row) and, for another person, usePublicProfile (the Header's sub). The score is 50 plus the points of the last 12 months, none before five recorded sessions: then the meter is the steps to it (n/5) and its line says when it shows. The page model (buildTrustPage) turns the ledger into rows, one per session: attended +1 and late −1 read as one row ("12분 늦음", 0), a new praise rides on its attended row ("칭찬 1", +2), an upheld report with no session stands alone. The cap line (oneself only) counts the weighted absences held (late cancel 0.5, no-show 1) against rsvpBlockStrikes and names the day the number gives way. The page does not use QueryBoundary: useCompositeNetwork reports the loading, failed and cold-offline legs, and a cached record stays readable under the app's own strip. Official mode is another person's record with a club (page.official): OfficialRecordView adds useClub (the sub), useClubMemberSessionStats (가입 후 N회 중 참석) and useOverdueDues folded to the member's unpaid count, and draws only a record whose viewer is official (else the permission wall, never a role guess). buildOfficialNotice reuses the cap model against ATTENDANCE_FLAG_THRESHOLD and names the weighted absences, the threshold and the day the flag lifts.

Now (사유 인정 요청). The record items carry askableUntil (the last Korea day a request can be sent; the server decides) and request (AttendanceExcuseRequest: status, origin, category, note, canAnswer, undoUntil); buildRecordRows copies both onto RecordRow. trust-request.ts is the requester's pure reading: rowRequestTag(row) is the row's Tag and recordAsk(row, today) is what the Sheet offers. The request is read first: inside the 10 minutes after an answer the server shows the requester pending whatever the answer, while the record itself may already read 사유 인정. An accepted request draws no Tag on the row (the record reads 사유 인정, 0 points); only its Sheet keeps the Tag 인정됨 and the rule 운영진이 인정. A declined one is final (Tag 거절됨, no button for anyone; an official cannot excuse that record directly either). An official's own direct excusal (origin: official) is no request the person made and draws no Tag. The category and the note reach only their author and a decider: the officials' record shows them from the record JSON, the officials' read is persist-denied, and neither is in a signal, a push, a log or the telemetry. The count 사유 인정 N회 / 최근 12개월 counts only requests accepted in this club (never the system's excused), and only for a request that waits for the viewer. Authority is the server's one helper (private.may_answer_attendance_excuse): every control follows request.canAnswer, undoUntil or the server's refusal, never a role check on the page. Re-joining after an accepted excuse leaves the record excused.

Anatomy.

├─ [DetailShell] title 내 신뢰 기록 | 신뢰 기록 (sub: the person) | 멤버 기록 (sub: the club)
└─ status: loading | offline | error | missing | ready
   loading → [SkeletonKind cell] [strip] [media ×4] (official: a [Block identity loading] first)
   ready, nothing recorded → [EmptyState first] (oneself: + 일정 찾기 → 경기 hub; another person: title only)
   ready →
   ├─ [Gap 12] [SectionHead first 신뢰 · action 점수 안내]
   ├─ [TrustMeter] score 72점 + bar with tick at 시작 (50) | steps 3/5 + line
   ├─ [Gap 16] [Strip tiles] 최근 20회 중 참석 · 연속 참석 · 칭찬해 준 사람 (before the score: 참석 · 연속 참석) — only the fields the viewer may see
   ├─ [SectionHead 참석 기록] [Notice attention: the cap in numbers] (oneself, while absences are held)
   ├─ [MediaRow kind=venue] × 7 (court tile · l1 club · l2 day | fact | 칭찬 N · trailing PointsFigure) + [ExpandToggle N개 더 보기]
   ├─ (oneself) [CellGroup] [Cell nav 기록 문의]
   ├─ [ExplainerSheet 신뢰 점수 안내] — one two-line Cell per event, its points at the right
   └─ [Sheet bleed] the record: 적용한 규칙 · 점수 · 기록에서 빠지는 날 (a deduction) · [Cell nav 일정 보기]
   official (record.viewer = official) →
   ├─ [Gap 8] [Block identity] the member (no 신뢰 door) · [SectionHead 신뢰 · 점수 안내] · [TrustMeter]
   ├─ [Strip tiles] 가입 후 N회 중 참석 · 지각 (no 연속 참석: the server hides it)
   ├─ [Gap 24] [CellGroup] [Cell nav 회비와 참가비 · 미납 N건 | 완납] → 회비 관리
   ├─ [SectionHead 참석 기록] [Notice attention: 불참 N건으로 출석 주의 기준(3건)을 넘었어요. {day}에 풀려요]
   ├─ [MediaRow] × 7 (l1 the venue · this club's records only) + [ExpandToggle]
   ├─ [MediaRow kind=venue] l2 = day | fact | Tag (확인 중 | 거절됨); a request that waits for this viewer carries an [AnswerPair 사유 인정 · 거절] under the row, rail to rail (the kit has no inset for a media row's answer)
   └─ [Sheet bleed] the record · the reason (category, then the note) · 사유 인정 N회 / 최근 12개월 · the request's Cell with its Tag · 되돌릴 수 있는 시간 · footer 되돌리기 (inside the 10 minutes, the answerer only) or 사유 인정 (an absence nobody asked about) → [Dialog] naming the person (a refusal replaces its body; a final one leaves only 닫기)
   own record: [MediaRow] l2 = … Tag; [Sheet bleed] the record · the reason shown back · [Cell variant=tag 사유 인정 요청 · Tag] · footer 사유 인정 요청 (the seven days run) → [ExcuseRequestSheet] (back → the record)
   not an official → [EmptyState permission] 운영진만 볼 수 있어요 (a 매치's creator who is neither owner nor admin of its club can answer on the server but reads the wall here: recorded gap)

States. loading · failed (one retry) · cold offline · record missing (NotFoundState) · day one (oneself: 일정에 참석하면 신뢰가 쌓여요 with 일정 찾기; another person: 아직 쌓인 기록이 없어요) · under five sessions (steps, count tile, no score figure) · full record · by viewer level: oneself (everything), official (score, 지각, this club's rows), context (score, last 20), stranger (score only) · cap line room / at the cap · explainer Sheet · record Sheet. Officials' view: ideal (tr-m-ideal) · good standing (tr-m-clean) · loading (tr-m-loading) · wall (tr-m-wall) · absence Sheet with 사유 인정 (tr-m-record-excuse) · the Dialog (tr-m-accept) · 200% text (tr-m-200) · dues unread (the Cell without a value) · flag Notice at / over the threshold.

  • Offline (F1c, the app's root strip says it): unchanged rule: a cached page shows offline under the root strip and only its stale test is named. (tr-stale)

States (사유 인정 요청). A row: no request (no Tag) · 확인 중 (a waiting request, or any request inside the 10-minute window) · 거절됨 (final) · accepted (no Tag, 사유 인정, 0 points). The Sheet: askable (the seventh day is the last) · requested (the reason shown back, Tag by state, no button) · over (the eighth day: no button, the Cell says 7일이 지났어요) · none (a record that costs nothing, or an absence nobody could answer: no button) · the form. Officials: waiting (pair on the row, no footer) · accepted inside the window (rule 운영진이 인정, 0 points, time left, 되돌리기; the footer and the time vanish at the window's end by useNowTicking) · accepted after it (Tag 인정됨) · declined (no button for anyone) · direct excusal (same 되돌리기, no reason, no Tag) · the official's own record (no answer, no 사유 인정). English: 확인 중 is "In review", 인정됨 "Cleared", the button "Request excusal". Offline: the record and the request state are the person's own data and show cached; opening the form navigates (offlineOk); the three writes are blocked offline by their hooks (their controls carry write); the officials' waiting requests are a staff read, persist-denied and not drawn cold offline.

  • 2026-11-13 (UX-7, the state helper): useTrustRecordPage decides with pageState; PageStateView: loading (one skeleton per zone), cold offline, error, record missing (door 프로필 → the profile tab). The officials' view (멤버 기록) is a hidden read: offline it is the offline state even with a warm copy; the own record stays cached.

Actions. 점수 안내 → explainer Sheet. A row → its Sheet; 일정 보기 → 일정 상세 (routes.clubSession; none for a report with no session). 기록 문의 → /legal/contact. 일정 찾기 (day one) → 경기. N개 더 보기 expands in place. Official: 회비와 참가비 → routes.clubDues(clubId); 사유 인정 (Sheet button, an absence nobody asked about) → Dialog → useExcuseAttendance; 사유 인정 / 거절 on a waiting row → Dialog → useAnswerAttendanceExcuse; 되돌리기 (the Toast, 8 s, or the Sheet's footer) → useUndoAttendanceExcuseAnswer. Oneself: the footer 사유 인정 요청 → the form; 요청 보내기 → useRequestAttendanceExcuse (the Toast 요청을 보냈어요, both Sheets close). Each refusal the server names (excuse_* hints) is its own line over the footer or on the Dialog that stays open, never a Toast; a final one (excuse_already_answered, excuse_declined_final, excuse_self) is not offered again and the row behind refreshes to the true state. Doors that land here with the club: 출석 현황's row (180), 회비 관리's person Sheet name row (184), 멤버 관리's Sheet 멤버 기록 row (218).

Gaps. The Tag on the row names the state only; the answer reaches the requester by notice 10 minutes after the answer. A 매치's disclosure line is chosen from the club's type read for the opened row (useClubsByIds); the board's "총무" / "treasurer" wording is replaced by the app's excuse.disclosure lines. The Dialog has no danger-line slot, so a refusal replaces its body text. 회비와 참가비 reads the dues only (no 참가비 figure until a read for another member's fees exists). The meter's "지난달보다 N점" line is not drawn: the record carries no score a month ago. The row's thumbnail is the court tile (the record carries no venue coordinates for the map). TrustMeter is a page-local piece until the kit takes one. The board's route for an official (/clubs/[clubId]/members/[userId]) is not used: the officials' view is this route with clubId. The category 아픔, 부상 and the note can be health information: a legal check before launch is the owner's to arrange (docs/release-evidence.md).

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