Skip to content

Screen Blueprints — Clubs ​

Status: Active Last reviewed: 2026-11-01

Current-state blueprints for the clubs 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.


Club List (클럽) ​

  • Route: /(tabs)/(clubs)
  • Screen: ClubListScreen — packages/features/clubs/src/club-list-screen.tsx (the panes, rows and filter are club-list/)
  • Shell: MainTabShell · chrome="tabs" · title t().common.clubsTab (클럽) · actions: the search glyph on 클럽 찾기 only (none on 내 클럽, which has nothing to search) · the Header's search mode puts the field in place of the title · no bottom CTA band
  • Design: unreviewed
  • Verified: 2026-11-13
  • Notes: 2026-11-13 (UI/UX pass, UX-1): rebuilt to the list board (flat-v2-list-a.jsx, frames club-*); the owner has not seen it, so the earlier ratified (against the photo cards) is reset. The ClubCard, its corner badges and emoji fact chips, the header + menu, the newcomer's CTA band and the "quiet last row" are gone; 클럽 만들기 has one home, the FAB. The tab was 탐색 and is 클럽 찾기. A club that offered the viewer membership is a relation of the row (UX-15): ClubRelation has offered, drawn by RELATION_SLOT as the state Tag 가입 제안이 와 있어요 with the task meaning (the viewer's to answer), where the viewer's own pending request is 승인 대기 with waiting. Both come from ONE bulk read of the viewer's pending join requests (useMyPendingClubOffers, useMyPendingClubRequests; one request for the page, never one per row). A member row's state Tag is decidingFact (the 경기 tab's rule). The admin snapshots are still read once for the page (useMyClubAdminRows); what an official has waiting is the row's state Tag, not a corner badge. See canon/clubs.md.

Purpose. The viewer's clubs tab: their clubs, the clubs they are a guest of and the archived ones in 내 클럽, and the clubs they could join in 클럽 찾기, as one kind of row; and the one way to make or join a club (the FAB).

Anatomy.

├─ [MainTabShell chrome=tabs] title=클럽 actions=[search] (클럽 찾기 only) · search={field, 취소} while the Header searches
├─ tabs = [내 클럽, 클럽 찾기] (Tabs, preload="visited")
├─ ▸ 내 클럽 → [MyClubsPane]  (useMyPendingClubOffers + useMyPendingClubRequests (one bulk read), useMyClubs, useMyGuestClubs + useClubsByIds, useClubUpcomingSessions, useSessionCardModels once for the next events, useClubDiscoveryStatsByClubIds once, useMyClubAdminRows once)
│   ├─ [GroupedFeedList] 16 under the Tabs (24 when the first group has a head)
│   │   ├─ no head: offered clubs first ([ClubFeedRow relation=offered]), then [ClubFeedRow] members sorted by next event, then region (sortClubsByNextSessionAndRegion), then the clubs the viewer's own request waits on ([ClubFeedRow relation=pending])
│   │   ├─ ▸ "게스트 클럽" [SectionHead] + [ClubFeedRow relation=guest]   — only with a guest invite
│   │   └─ ▸ "보관한 클럽" [SectionHead] + [ClubFeedRow] archived (no who row; its footer says when it was archived)
│   └─ [ClubCreateFab]
├─ ▸ 클럽 찾기 → [DiscoverPane]  (useViewerDiscoverClubs: the query, its paging and the filters are unchanged)
│   ├─ [FeedList] 24 under the Tabs, [FilterHead title=추천 클럽] (필터, or 필터 N while N facets are on; one removable Choice per value under it)
│   ├─ [ClubFeedRow] per discovered club (sortClubCardModelsForDiscovery), the thumb the featured photo, else the logo
│   ├─ [ClubFilterSheets] FilterSheet (지역 Picker Cell, 종목, 요일, 시간대, 가입 방식, 수준 band) + RegionPickerSheet
│   └─ [ClubCreateFab]
├─ ▸ searching → [ClubSearchResults] 12 under the Header, [SectionHead first 검색 결과], the same rows narrowed by the filters that are on; no FAB
└─ [ClubCreateFab]  Fab "클럽" (aria 클럽 만들기, placed=tab; held offline by the kit, as the sessions feed's FAB; not drawn on the cold-offline frame) → MenuSheet "클럽": 클럽 만들기 → /create-club · 초대 코드로 가입 → /join-club

A row is the kit's Feed item (ClubFeedRow; the words are clubRowModel): the overline is the region and the focus; the facts are the level band, the composition and the format (a mixed club that plays doubles is one fact, 혼합복식); the who row is the rhythm and the dues and the gate (the seats left, 승인 후 가입, 초대 전용; a guest club says 게스트 in the gate's place), or, for a member's club, its next event and the viewer's own answer to it ("다음 일정 토 19:00", 참석 / 미납 / 예비 / 호스트, plain text as the board draws it). The one state Tag after the title is, in this order, the work waiting for an official (the TOTAL of the club's attention parts: one kind names itself, "가입 3"; several are "할 일 5"; capped at 99+; meaning task), or a relation or gate that carries a meaning (승인 대기, 정원 마감); a member who is not an official has no Tag. A member's row ends with the unread Count tag (3, 12, 99+; none at 0) from ONE bulk read of the live memberships (useUnreadPostCountsByClubIds, the RPC get_unread_post_counts_by_club_ids), refreshed by reading a post, a deleted post and the club post notices. The relation is one value of ClubRelation (club-relation.ts) and one line of its table.

States.

  • 내 클럽 loading → five Feed item skeletons; failed → QueryErrorState (retry refetches the memberships and the guest list); cold offline → OfflineEmptyState, never the empty state. A guest-only viewer waits for the guest list before any empty state.
  • 내 클럽 empty (no club, no guest invite, no offer, no pending request; a viewer with only an offer or only a request has a list) → EmptyState first (Users, 아직 가입한 클럽이 없어요; the body also points to the + that creates a club), its one action 클럽 찾기 switches to that pane. A list that holds only archived clubs is populated, with the 보관한 클럽 head first.
  • 클럽 찾기 loading, failed and cold offline as above; empty → EmptyState first compact (탐색 가능한 클럽이 없어요); with filters on and nothing left → EmptyState filtered (조건에 맞는 클럽이 없어요), whose action 필터 초기화 clears the filters.
  • Offline (F1c, the app's root strip says it): the cached lists stay under the strip; an official's task Tag is staff data and is not drawn offline (useMyClubAdminRows is empty); the FAB follows the sessions feed's: the kit holds it offline, and it is not drawn on the cold-offline frame.
  • ?tab=discover / ?tab=my (routes.discoverClubs) opens the matching pane on mount and on later param changes.

Actions.

  • Tab switch 내 클럽 ↔ 클럽 찾기.
  • An offered or 승인 대기 row opens the club page (routes.club), where the visitor state holds the answer; the row has no button.
  • A member, guest or discovered club's row → routes.club (the home; a non-member sees the same page); an archived club's row → routes.clubSettings (the recovery view).
  • FAB 클럽 → the menu; 클럽 만들기 → routes.createClub; 초대 코드로 가입 → routes.joinClub.
  • 검색 (클럽 찾기) → the Header search; typing narrows the same query by name and description; 취소 returns.
  • 필터 ▾ → FilterSheet; 지역 opens the 시/도 then 시/군/구 Sheet (the query takes one 시/군/구, so the Sheet picks one: a second district replaces the first and the Choice names the one applied); a Choice's × clears only its facet; 초기화 on the Sheet clears the draft; the primary 적용 applies it.
  • Pull to refresh on both panes; 클럽 찾기 infinite scroll (onEndReached).

Wide windows (web). The tab root's column stops at the reading width (680); phones unchanged.

Gaps. The Sheet's primary says 적용, not "결과 N개 보기": the results are paged by the server and a draft has no count. A member's row opens the club (one door per row); HEAD's card listed two upcoming sessions, each a door, which the row does not (the board draws the next one as a fact). The thumb hue is a stable function of the club id, not the club's chosen mark colour. Device acceptance (scrolling, the FAB's collapse, the keyboard over the Header search) is pending.

Club Album (앨범) ​

  • Route: /clubs/[clubId]/album
  • Screen: ClubAlbumScreen — packages/features/clubs/src/club-album-screen.tsx (the list and its states are album/album-feed.tsx; the grouping is album/album-groups.ts and album/album-sections.ts, pure; the reads are album/use-club-album.ts; the grid math is club-album-grid.ts)
  • Shell: DetailShell · title strings.albumTitle · sub the club's name · scroll={false} (the list scrolls) · headerRight: none
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: loading, error and offline are the app's one PageStateView in the page Header; a club that cannot be seen is not found and offers 클럽으로 (a real gap, now closed); a warm copy shows offline.

Purpose. The club's approved photos, newest first, grouped by the upload they came with. A member-facing viewing surface reached from the album banner on the club home, the Feed's 사진 item and (later) 앨범 관리's door; not a tab. Built W4 batch 5 to flat-v2-list-a#al-ideal (L3-23).

Anatomy.

├─ [DetailShell] title=albumTitle sub=club.name onBack scroll=false
└─ [FeedList] perfLabel=club-album contentContainerStyle=SCROLL_CONTENT.list · pull to refresh (usePullToRefresh)
   ├─ ▸ item (one per group, newest first) → [AlbumSectionView]
   │     ├─ (first group) 12 spacer under the Header
   │     ├─ [SectionHead] first? title=<date + sessionTitle() | "9월 18일 (금) 사진"> count="6장" action=일정 상세 (a session's group only)
   │     └─ [PhotoGrid] label=title tiles=[{uri, thumbhash, label}] tileSize=albumTileSize(contentWidth, 2)
   ├─ ▸ loading → 16 spacer + [PhotoGrid loading] (twelve gray squares at the loaded pitch)
   ├─ ▸ empty (no approved photo) → [EmptyState] kind=first glyph=Image title=albumEmpty (no body, no button: uploads live elsewhere)
   ├─ ▸ error (either read) → [EmptyState] kind=error what=사진 · retry refetches both reads
   └─ ▸ cold offline (nothing fetched) → [EmptyState] kind=offline · retry

Groups. A photo uploaded for a session (club_media.session_id) groups under that session; the head is the session's date and sessionTitle() (never session.title), its count N장 and the door 일정 상세 → routes.clubSession. A photo with no session, or whose session the viewer cannot read (deleted: the FK sets null; or hidden), groups by (uploader, the club's local day, Asia/Seoul) under M월 D일 (요일) 사진 with no door: the unit the club home Feed's 사진 item already shows. Groups run newest first by their latest photo; inside a group the officials' sort_order wins, then the newest photo. Approved photos only (pending ones live on 앨범 관리). A photo the storage would not sign has no picture and is left out of its group.

Reads. useClubMedia(clubId, {approvalStatus:'approved'}) — one bulk signing request per bucket (createSignedUrls, 30 minutes; a path the storage refuses has a null URL, a request that fails as a whole fails the read) and useSessionsByIds(ids) for the groups' sessions, one bulk read (Protocol A). A failed sessions read is the page's error state: a group cannot be named without it. The adapter keeps its cap of 60 photos, newest by the curated order; only the unlimited plan can pass it (every other plan holds at most 12), and nothing marks the cut.

States. loading / empty / error / cold offline / populated as above. Offline: with nothing cached the offline state, never an empty album; a cached album stays under the root strip (owner decision 16, 2026-11-10).

  • Offline (F1c, the app's root strip says it): offline with cached rows the page stays under the root strip; the FAB and write rows wait; cold offline is the offline state.

Actions. Back · pull to refresh · a head's door 일정 상세 → the session page. Tiles are display-only: the photo viewer has no board yet (owed, shared with the 회계 receipt viewer).

Accessibility. h1 앨범; each group's grid is a list named by its head, each tile a list item named 사진 N / M (N within the group) or by its alt text, else its caption.

Wide windows (web). Grid columns grow with the column width (club-album-grid.ts): 3 on a phone, 4 at 680, up to 6 at 1200; the gap stays 2.

Gaps.

  • The board's 게시글 door (a post's photos as an album group) waits: post photos are a separate pipeline (club_posts.image_urls, private bucket, no approval), not club_media; whether a member's post photo belongs in an approved album is a product decision, recorded for the owner.
  • The photo viewer (a tile tap) has no board.
  • No pagination beyond the 60 cap (above).
  • A club's time zone is not on the Club entity; days are cut in Asia/Seoul (the Feed's groups are cut by iana_timezone in SQL). Identical for every club today.
  • The no-legacy-feedback ratchet entry for club-album-screen.tsx can go: the screen no longer imports Skeleton, QueryErrorState or OfflineEmptyState.
  • Orphaned words: none in clubDetail (the screen still uses albumTitle, albumEmpty).

Club Analytics (성장 분석, L3-03) ​

  • Route: /clubs/[clubId]/analytics
  • Screen: ClubAnalyticsScreen — packages/features/clubs/src/club-analytics-screen.tsx (page in club-analytics/)
  • Shell: DetailShell · title t().common.growthAnalytics · sub the club's name · scroll · headerRight: none
  • Design: unreviewed
  • Verified: 2026-11-15
  • Sources: the head 활동이 많은 경로 counts every kind of event by its source (views, taps, starts, appearances), so its figures do not add up to the profile views; the funnel counts events too.
  • Batch 58 (ST1, 2026-11-13), States: The entitlement gate (locked) comes after the wall and before the figures.
  • Batch 58 (ST1, 2026-11-13), States: Same order, with the offline class hidden: another member's money, a staff queue or staff analytics is never drawn from a warm copy offline (the offline state instead). 가입 심사's request another official answered is the helper's not found and offers 신청 목록; a malformed id offers 클럽으로 or 신청 목록.
  • Notes: 2026-11-10 (W4 batch 21b): rebuilt to flat-v2-detail#ga. One tile row on top (이번 달 일정, 활동 멤버, 출석률, 회비 납부율), then one Cell group per question; the ProgressBar, the 탐색 노출 and 일정 관심 rows and the delta against the previous period are gone (the board cut them). 모집 계획 is new and needs no schema: 정원 is club.maxMembers, 필요한 멤버 수 and 한 주 참석 합계 are requiredCapacity and weeklyTotalAttendance over the active members, the group rosters and the regular meets at the club's 참석률 assumption (the basis the 클럽 설정 card used). 회비 납부율 is all time (get_club_analytics has no period filter), so no label says 이번 달.

Purpose. How people find and join the club, and how it runs, for the officials of a club whose plan offers growth analytics.

Anatomy. One gate (growthGate), then one state for the reads (useGrowthPage).

├─ [DetailShell] title=성장 분석 sub=club scroll
└─ gate (growth-gate.ts): cold → OfflineEmptyState · error → QueryErrorState what=성장 분석 · loading → bars
   · not-found → NotFoundState · wall → EmptyState permission · locked → EmptyState first (lock) · ready → GrowthContent
      ├─ ▸ loading: [Strip tiles loading ×4] + [SkeletonKind cell ×3]
      ├─ ▸ empty (no analytics row yet): [EmptyState first, chartLine] 아직 성장 데이터가 없어요
      └─ ▸ ready:
         ├─ [SectionHead first 운영] + [Strip tiles] × 4 (a dash where no session was held or nothing charged)
         ├─ [SectionHead 가입 흐름 · 최근 30일] + [CellGroup] 프로필 조회 · 가입 누르기 "312회 (25%)" · 신청 시작 · 신청 완료 "75회 (78%)" (event counts: 회, never 명)
         ├─ ▸ sources: [SectionHead 활동이 많은 경로 · 최근 30일] + [CellGroup] the top three (the discover feed reads 클럽 찾기)
         ├─ [SectionHead 모집 프로필] + [CellGroup] 프로필 완성도 · 공개 사진 · 승인 대기 사진 (Tag, nav) · 대기 신청 (count)
         ├─ [SectionHead 모집 계획] + [CellGroup] 정원 · 필요한 멤버 수 · 한 주 참석 합계, and ▸ short: [CaptionRow warn] 정원보다 N명 부족해요
         └─ 24 + [CellGroup top=false] [Cell nav 클럽 설정] · [Cell nav 멤버 초대] → [InviteSheet]

States. Order: cold, error, loading, not found, wall (not an official), locked (the plan does not offer it: no button, no date), then empty, then the page. A conversion that cannot be computed (the numerator is larger than the denominator, or the denominator is 0) shows a dash, never a wrong percentage (BE-7). A failed groups or meets read fails the page rather than draw a wrong plan. 승인 대기 사진 and 대기 신청 at zero are plain facts (0장, 0), not doors.

  • Offline (F1c, the app's root strip says it): offline with cached rows the page stays under the root strip; the FAB and write rows wait; cold offline is the offline state.

Actions. 승인 대기 사진 → routes.clubAlbumManage(clubId); 대기 신청 → routes.clubMembers(clubId) (its join requests are the list); 클럽 설정 → routes.clubSettings(clubId); 멤버 초대 → the club's invite Sheet (InviteSheet, get_club_invite_code). Everything else is read-only. Pull to refresh is not drawn (a scroll page). Back.

Gaps. The plan assumption (60% unless 클럽 설정 says otherwise) is not stated on the page, as the board has it. The English words for the funnel steps follow existing keys where the text matched, so a few differ from the board's English.


Club Attendance (출석 현황, L3-07) ​

  • Route: /clubs/[clubId]/attendance
  • Screen: ClubAttendanceScreen — packages/features/clubs/src/club-attendance-screen.tsx (page in club-attendance/)
  • Shell: DetailShell · title t().trust.attendanceDashboardTitle (출석 현황) · sub the club's name · scroll={false} (the list scrolls) · headerRight: none
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: The app's one PageStateView in the page's Header: offline (nothing cached) wins over error, error over loading, then not found (offers 클럽으로, the 클럽 tab), then the wall (the helper's no-permission). A warm copy shows offline. 멤버 관리 does not draw the 가입 신청 queue or the guest roster offline.
  • Notes: 2026-11-10 (W4 batch 21b): rebuilt to flat-v2-list-a#at. The three-tile Strip became two tiles under a 출석 head; the one flat list became 출석 주의 and 멤버 sections; the 주의 Tag is gone (the head says it); the door Cell moved from the head to the foot. 출석률 stays the ONE since-join formula (get_club_attendance_rates, migration 00661), pooled (Σattended over Σheld), never a mean of the rows' rates (BE-3). A clean row now opens the officials' record routes.profileTrust(userId, clubId); the flagged row keeps the 사유 인정 Sheet until that record takes the command over (phase 2).

Purpose. The officials' page for each member's show-up rate since joining, the flagged ones first (weighted absences of 3 or more), and a way into any member's record in this club.

Anatomy. One gate (attendanceGate), then one state for the reads (useAttendancePage).

├─ [DetailShell] title=출석 현황 sub=club scroll=false
└─ gate (attendance-gate.ts): cold → OfflineEmptyState · error → QueryErrorState · loading → AttendanceLoadingBody
   · not-found → NotFoundState · wall → EmptyState kind=permission (no button) · ready → AttendanceContent
      ├─ ▸ loading: [SectionHead first 출석] + [Strip tiles loading] + 24 + [SkeletonKind person ×7]
      ├─ ▸ empty (no member has a held session): [EmptyState first, clock] 아직 출석 기록이 없어요
      └─ ▸ ready: [FeedList]
         ├─ header: [SectionHead first 출석] + [Strip tiles] 가입 후 출석률 (pooled, a dash when nothing was held) · 대상 멤버
         ├─ ▸ flagged: [SectionHead 출석 주의 · N명] + [PersonRow] × N, lowest rate first (line 2 불참 3.5, figure 44 wide)
         ├─ ▸ others: [SectionHead 멤버 · N명] + [PersonRow] × N, highest rate first
         └─ footer: 24 + [CellGroup] [TaskCell 멤버 관리]

States. Cold offline: OfflineEmptyState with its retry (never an empty record). Failed read: QueryErrorState naming 출석 현황 with its retry. Loading: the head over two bars and seven person bars. Empty: no record yet. Wall: a member meets 운영진만 볼 수 있어요 with no button; nothing is read for them. A section with no row draws no head. The 사유 인정 Sheet says offline when its read is paused with nothing cached.

  • Offline (F1c, the app's root strip says it): offline with cached rows the page stays under the root strip; the FAB and write rows wait; cold offline is the offline state.

Actions. A clean row → routes.profileTrust(userId, clubId) (an official lands on the officials' record). A flagged row → the 사유 인정 Sheet (useMemberStrikeRecords), one 사유 인정 Button per record → a Dialog naming the member → useExcuseAttendance. 멤버 관리 → routes.clubMembers(clubId). Pull to refresh. Back.

Gaps. The member's request for 사유 인정 and the answer are on the officials' record (/profile/trust), not here. The Sheet's direct 사유 인정 runs useAttendanceExcuse: the confirm names the member, the write is useExcuseAttendance, the Toast 사유를 인정했어요 carries 되돌리기 for 10 minutes (useUndoAttendanceExcuseAnswer), and a refusal (including excuse_self on the official's own row and excuse_declined_final) is a line on the Sheet, never a Toast.



Board Post Detail (게시글) ​

  • Route: /clubs/[clubId]/board/[postId]
  • Screen: PostDetailScreen — packages/features/clubs/src/post-detail-screen.tsx (parts under post-detail/*)
  • Shell: DetailShell · title t().board.post · sub the club's name · headerRight: 공유 and ⋯
  • Design: unreviewed
  • Verified: 2026-11-15
  • Comments: 답글 달기 starts on the comment text's rail (the ghost Button bleeds its padding, ghostBleed); the composer is the same one-line-then-grow field as the chat's, its send ball on the last line.
  • Batch 58 (ST1, S, 2026-11-13), States and Actions: States come from the one helper (a post that is gone is not found and offers 클럽으로); a comment's own 삭제 (own) or 신고 (another's) is a direct control on the row and the ⋯ opens only where three or more rows exist (the post author on another's comment); the post header's ⋯ stays as the board draws it.
  • Notes: 2026-11-10 (W4 batch 19, lane P): rebuilt to the board flat-v2-detail#po (library "PostRow" page form, "Reactions", "Band", "Input", "Menu sheet", "Offline strip"). The TypeHero, BottomCtaBand, StyledInput and both TODO(K6b) markers are gone. Words in postPage (ko/en post-page.ts).

Purpose. The full view of one club board post — author, body, photos, per-emoji reaction chips and the talk beneath it, with a pinned comment composer — reached from the 홈 feed and from a club post notification.

Anatomy.

├─ [KeyboardAvoidingView] (iOS "padding"; Android no-op, relies on app.json pan mode)
├─ [DetailShell] title=게시글 sub=<club> actions=[공유, ⋯ (signed in)] scroll=false footer=▸[CommentComposer] | [CtaBand] 로그인하고 댓글 쓰기 (signed out)
│   └─ [FeedList] head=[SectionHead first "댓글" ▸ door "이전 댓글 더보기"]
│       ├─ header → [PostDetailHeader]: 16 · rail gap $3: author [PersonRow] (opens 공개 프로필) → audience [Tag] → title/body [AutoLinkText] → photo blocks 262/r12 → ▸ session link row → [PostActionRow] (▸[Reactions summary] chips · heart (no number) + comment count "99+") · [Band]
│       └─ rows → [MemoCommentRow]: byline [PersonRow] (opens 공개 프로필, ⋯ control) → body (reading) → ▸ ghost 답글 달기
├─ [MenuSheet] heart: quick row, then 댓글 달기 · comment ⋯: 답글 달기 / 신고 (+ 삭제 for the post's author) · own comment: 삭제
├─ header ⋯ menu: author 삭제 (confirm Dialog: the comments go too) · reader 신고 (ReportModal, names the author)
└─ [ReportModal]

States. Board frames, each a screen-test state in __tests__/post-detail-screen.test.tsx: po-ideal; po-reply (a 44 row names the comment above the field); po-empty; po-cload; po-cerror (a quiet Notice in the section, the post stays readable); po-many (count caps at 99+); po-loading (skeleton post, Band, head, three person rows); po-stale (cached post under the root strip); po-out (the band asks for sign-in and returns to this post); po-notfound (door 클럽으로); po-offline (cold start, OfflineEmptyState); the query error. Overlays: po-o-react, po-o-menu, po-o-menu-other, po-o-cmine, po-o-cother, po-o-delete.

  • Write failures (ruling EP-3): 삭제 (post or comment) and 공지 are told in a Dialog in the app's words, 닫기 forgets them; a comment that fails says it on the helper line above the field; never a Toast.

  • Offline (F1c, the app's root strip says it): offline with a cached post the page stays under the root strip, with no strip of its own (po-stale); the comment send and the post and comment ⋯ writes wait (reactions queue).

Actions. Toggle a reaction chip; the heart opens the 6-type quick row and 댓글 달기 (focuses the composer); 공유 → native share; header ⋯ → 삭제 (author, confirm, then back) or 신고 (reader); comment ⋯ → 답글 달기 / 신고 / 삭제; the author and every commenter open routes.userProfile (door 253, L3-41); "이전 댓글 더보기" pages in earlier comments; send (soft limit 500, past it the field shows its error edge and the send waits; on the web Enter sends and Shift+Enter adds a line, on a phone the return key adds a line and the send ball sends); pull-to-refresh (post + comments + reactions); mark-read fires once per post.

Gaps. Board frames not built, with the reason (2026-11-10): po-cdeleted (a deleted comment that keeps its replies): PostComment has no deleted state and no parent id, so replies are flat @mentions; 수정 rows on the post ⋯ and the comment ⋯: no edit store or route exists; a signed-out viewer reads no comments (the list stays gated off for them, as before) where the board draws them; the page's photos are all drawn as blocks (the board's two-squares-with-"+N" form would hide photos the page cannot open); the kit PostRow has no page form, so the post is composed from the kit's PersonRow, Reactions and Band. A report names the post's or comment's author (ReportContentInput carries no post or comment id).


Board Create Post (글쓰기) ​

  • Route: /clubs/[clubId]/board/create
  • Screen: PostCreateScreen — packages/features/clubs/src/post-create-screen.tsx
  • Shell: DetailShell · title t().board.createPost · sub the club's name · headerRight: none
  • Design: unreviewed
  • Verified: 2026-11-12
  • Notes: 2026-11-02 (W4-1 lane B): rebuilt to the board flat-v2-forms#wp-ideal (library "Field", "TextArea", "Composer photo tiles", "CTA band"). The parts live under post-create/: post-fields (the five fields), use-post-composer (the view model), use-post-draft, use-post-photos, use-post-publish (post and leave), post-rules / photo-list / post-input / post-band (pure).

Purpose. The 글쓰기 composer for a club's board — a category, an optional title, the body, up to 4 photos and, for an official, an audience. The body is the one required field.

Anatomy.

├─ [DetailShell] title="글쓰기" sub=<club name> onBack footer=[CtaBand]✦올리기 (none while loading)
└─ [YStack paddingTop=$6]
    ▸ loading (useClub / useClubMembers) → [FieldSkeleton n=3]
    ▸ else [YStack paddingHorizontal=rail gap=$8]
        ├─ [ChoiceSet legend=분류] of [Choice]×3 — 자유/경기 후기/소개 (all) + ▸공지/운영진방 (canManageClub, ×5); `?category=` deep-links preselect
        ├─ [Field 제목 optional] → [Input limit=80] (counter from 64; past 80 → the Field's line, the band held)
        ├─ [Field 내용 required] → [TextArea limit=4,000] (counter always; past it → "−12" and the line "내용은 4,000자 이하로 입력해 주세요", the text kept; 올리기 pressed on an empty body → "내용을 입력해 주세요" and the caret goes in)
        ├─ [Field 사진 optional] → [PhotoTiles limit=4] — 4-up tiles, × the one removal, a tile's menu 앞으로 이동 / 뒤로 이동, the add tile "n/4" (gone at 4)
        └─ ▸ [ChoiceSet legend=공개 범위] of [Choice]×2 (멤버/공개) — officials only, absent in 운영진방 (forced official)

States. Untouched: 올리기 soft (a press shows the body's line); typing: the counter and an enabled band; a photo uploading: its tile is a spinner, its × waits, the band is held; four photos: no add tile; past the limit: kept and flagged, band soft; offline: band held (the strip is the root slot's); submitting: 올리는 중...; failed: a danger line above the band (the mutation hook's toast says why); loading: a skeleton and no band until the club and its members have answered; a member's deep link to 공지/운영진방 falls back to 자유; 운영진방 forces audience='official'; leaving a written post asks 나갈까요? (useUnsavedChangesGuard; written = a title, a body or a photo) and unposted uploads are removed with useRemoveClubPostMedia when the page goes.

  • Write failures (ruling EP-3): a refused post says its line (including the daily limit) on the CTA band's helper; a photo that fails says errors.imageUploadFailed on the photo field; never a Toast.

  • Offline (F1c, the app's root strip says it): offline the form stays and its band or write rows wait (the kit holds the primary; no page-made disabled flag).

Actions. Pick a category; type title/body; add a photo (resize-then-upload through usePickImages, shown at once); open a tile's menu to move it; remove a photo with its ×; pick an audience; 올리기 → useCreatePost then appRouter.replace(routes.clubPost(...)) (the guard is released first, so posting never asks).

Gaps. The CtaBand keyboard prop (inset 0 with the keyboard up) is not wired: no keyboard-visibility hook exists in the repo. A photo still uploading when the page is left is not removed (its mutation callback never fires after unmount).


Club Challenges (클럽 대항전, L3-10) ​

  • Route: /clubs/[clubId]/challenges
  • Screen: ClubChallengesScreen — packages/features/clubs/src/club-challenges-screen.tsx (route only; the page and its parts under club-challenges/*)
  • Shell: DetailShell · title t().common.interClubChallenges ("클럽 대항전") · sub club.name · headerRight the "+" (대항전 신청) for an official once the club's plan is known · scroll=false
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: The app's one PageStateView in the page's Header: offline (nothing cached) wins over error, error over loading, then not found (offers 클럽으로, the 클럽 tab), then the wall (the helper's no-permission). A warm copy shows offline. 멤버 관리 does not draw the 가입 신청 queue or the guest roster offline.
  • Notes: 2026-11-10 (W4 batch 21b, board flat-v2-list-a#ch): rebuilt from the boxed blocks to Feed items; the answer is the server's respond_to_challenge. 2026-11-12 (batch C, rulings C2 to C9): the standing is the server's count of the matches (useClubChallengeStandings, joined to the list by challenge id; a challenge with no row is proposed, declined or cancelled); the page keys on settled and counted_matches, never on scores above zero; the proposal form, the opponent picker, the plan wall on proposing alone, 철회 and 취소.

Purpose. The club's challenges against other clubs: the requests waiting, the ones running and the past ones, with the live standing, the wait for the frozen result and the result itself; the answer to a request the club received, and, for an official, proposing a challenge, withdrawing a waiting proposal and cancelling a running one that has no counted match.

Anatomy.

├─ [DetailShell] title=클럽 대항전 sub=club.name onBack scroll=false actions=[+ 대항전 신청 (an official; only once the plan is read; only on the list or the empty state)]
├─ ▸ cold offline → [OfflineEmptyState] · read failed (either read) → [QueryErrorState] · club missing → [NotFoundState]
├─ ▸ loading (either read) → [SectionHead first] 전적 + [Strip tiles loading] + [FeedItemSkeleton who ×4]
├─ ▸ empty → [EmptyState kind=first] Swords 아직 대항전이 없어요 + action 대항전 신청 (an official only)
├─ ▸ ready → [FeedList]
│  ├─ ▸ a frozen challenge had a counted match → [SectionHead first] 전적 + [Strip tiles] 승 · 패
│  ├─ [SectionHead] 대기 중 (N건) + [ChallengeFeedRow] ; a request the viewer may answer carries [AnswerPair] 수락 | 거절 ; our waiting proposal carries [Button tonal sm full] 철회
│  ├─ [SectionHead] 진행 중 (N건) + [ChallengeFeedRow]×N ; a running one with no counted match carries [Button tonal sm full] 취소 (an official of either club)
│  ├─ [SectionHead] 지난 대항전 (N건) + two [ChallengeFeedRow] + [ExpandToggle] "N개 더 보기"
│  ├─ ▸ 수락 / 거절 pressed → [Dialog] (as before)
│  ├─ ▸ 철회 / 취소 pressed → [Dialog] danger: names the club, "상대 클럽에 알려 드려요" · a refusal → [Dialog] over the page (challenge_has_results: 경기 결과가 있어서 취소할 수 없어요 / 확인)
│  └─ ▸ the plan wall (a club below the plan, on 대항전 신청 only) → [Sheet] 대항전 신청은 클럽 플랜에서 할 수 있어요 (no button)
└─ ▸ 대항전 신청 pressed → `routes.clubChallengeNew(clubId)` (a club below the plan: [Sheet] instead)

ChallengeFeedRow: [FeedItem] thumb pair of both marks · overline 대항전 | the rule (총 승수, ELO 상승, 승률; an old 경기 수 row reads 출전 인원) · title "vs {club}" (2 lines) · facts the period and, within 7 days, D-N 시작 / D-N 종료, or, after the end date, D-N 결과 확정 · who the standing from the server's row: N-N 앞서는 중 / 뒤지는 중 / 동점 (the tally is the matches; the word is the rule's lead, so 뒤지는 중 can sit beside a tally in our favour); 승 / 패 / 무 N-N once frozen; 경기 없이 끝났어요 when no match counted; 받은 신청 (task) or 신청했어요 on a proposal; a declined, cancelled or ended one with no standing carries its ChallengeStatusBadge.

States. The three settle states come from challengeSettleStateOf: running (진행 중), ended and not frozen (지난 대항전, 결과 확정 D-n), frozen. A member (not an official) reads every row and meets no pair, no 철회 and no 취소. A request past its dates is ended and offers no pair. The proposal form: empty (보내기 waits), filled (the preview line), sending, and one refusal at a time as its own line on the form that stays open: the pair already has an open 대항전 and an opponent that is gone under 상대 클럽, a period over 90 days under 종료일, the daily limit and the plan under the band (never a Toast). A new answer takes the old refusal away. The action is not drawn while the role or the plan is read, when the plan read failed (a failed read is not a free plan), or on a cold-offline page. A club below the plan opens the plan Sheet instead of the form; receiving and answering are never walled. A 철회 or 취소 refusal after which it can never succeed (challenge_has_results, challenge_not_cancellable, challenge_not_pending, challenge_ended, challenge_settled) reads the list and the standings again and the row stops offering it.

  • Offline (F1c, the app's root strip says it): offline with cached rows the page stays under the root strip, the list and standings drawn from the cache; the writes (보내기, 철회, 취소, the answer) wait on the kit's write gate; cold offline is the offline state.

Actions. Row → routes.club(otherClubId) (always); 수락 / 거절 → the Dialog → useRespondToChallenge; 철회 and 취소 → the Dialog → useCancelChallenge({ challengeId, homeClubId, awayClubId }); the header "+" and the empty state's 대항전 신청 → routes.clubChallengeNew(clubId), or the plan Sheet below the plan; N개 더 보기; pull to refresh (refetches the list and the standings).

Gaps. The 종료일 Sheet lists days up to 365 from its first day and the 90-day reach is said under the period (form.endDateNote), not under the Calendar: the kit's DaySheet has no note slot. The row speaks the kit's default name, not the board's prose.


Club Challenge Proposal (대항전 신청, L3-10) ​

  • Route: /clubs/[clubId]/challenges/new
  • Screen: ClubChallengeNewScreen — packages/features/clubs/src/club-challenges/club-challenge-new-screen.tsx (renders ProposeFlow, propose-flow.tsx; the form is propose-form-view.tsx, the picker club-pick-takeover.tsx)
  • Shell: DetailShell · title t().clubChallengesPage.propose ("대항전 신청") · sub club.name · footer CtaBand 보내기
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: The helper's order, then the wall for a viewer who is not an official, then the plan's loading, error and Sheet.

Purpose. An official of a club whose plan includes challenges asks another club for a 대항전: the opponent, the rule (총 승수 or ELO 상승) and the period. Built to flat-v2-list-a#ch-form-empty, -filled, -date, -pair, -period, -gone, -limit, -sending, ch-sent and ch-pick-empty, -search, -none.

Anatomy.

├─ ▸ gate (`proposeGate`, the same one that offers the action): cold offline → [OfflineEmptyState] · failed → [QueryErrorState] · role or plan loading → [TextRowSkeletons] · club missing → [NotFoundState] · not an official → [EmptyState kind=permission] · below the plan → bare page + [PlanSheet] (closing it goes back)
└─ ▸ ready → [DetailShell] title=대항전 신청 sub=club.name onBack footer=[CtaBand]
   ├─ [CellGroup] [PickerCell] 상대 클럽 (required) · [PickerCell] 시작일 · [PickerCell] 종료일 (rows first, § 1.22 rule 2)
   ├─ [CaptionRow] the 종료일 reach note, "시작일부터 90일까지 고를 수 있어요", while the request is not whole
   ├─ [ChoiceSet] 방식 총 승수 | ELO 상승 · then [CaptionRow] "14일 동안, 총 승수로 겨뤄요" once whole
   ├─ [CtaBand] 보내기 (soft until whole · 보내는 중... while it writes · the danger helper for a refusal that is about no field)
   ├─ [DaySheet] 시작일 (nothing before today) · [DaySheet] 종료일 (the days up to the start are off; the Calendar reaches 365 days)
   └─ ▸ 상대 클럽 pressed → [SearchTakeoverHost] (the app's full-screen search layer: Modal, safe area, search bar with 취소) over the form → the discover feed (the viewer's own clubs and the proposing club left out; no discover impression is recorded) as [MediaRow kind=club] under [SectionHead] 클럽 찾기 / 검색 결과 + 6곳 · none → [EmptyState compact] 검색 결과가 없어요

States. Empty (보내기 waits), filled (the preview line), sending, and one refusal at a time as its own line on the form that stays open (pair under 상대 클럽, the opponent gone under 상대 클럽, a period over 90 days under 종료일, the daily limit and the plan under the band); a new answer takes the old refusal away. A later 시작일 that leaves no room for the 종료일 clears it. A plain member who lands on the URL meets the permission state; a club below the plan meets the plan Sheet; nothing is drawn as a form while the role or the plan is read, and a failed plan read is an error, not a free plan.

  • Offline (F1c): 보내기 is a write (the CtaBand primary) and waits on the kit's write gate; cold offline is the offline state; the picker's feed shows cached or the offline state.

Actions. 상대 클럽 → the picker (hardware back and 취소 step the picker back to the form with its answers intact); a picked club returns to the form; 시작일 / 종료일 → the Sheets; 보내기 → useProposeChallenge({ homeClubId, awayClubId, challengeType, startDate, endDate }) (nothing else can be sent: no status, score, winner or creator), then appRouter.replace(routes.clubChallenges(clubId)) (back never returns to a sent form; the Toast is the mutation's); back → appRouter.back().

Gaps. The 90-day reach is a CaptionRow under the period, not under the Calendar (the kit's DaySheet has no note slot). A dirty form is not guarded on leaving.


Club Dues (회비 관리, L3-11) ​

  • Route: /clubs/[clubId]/dues
  • Screen: ClubDuesScreen — packages/features/clubs/src/club-dues-screen.tsx (renders ClubDuesPage, packages/features/clubs/src/club-dues/page/club-dues-page.tsx; the file is only the shell, ClubDuesBody retired in W4 batch 14 with the bulk mark-paid, and a member's dues are the 회비 pane of 클럽 홈, club-detail/dues-pane/)
  • Shell: DetailShell · title t().common.manageDues · sub the club name · headerRight the ⋯ (회비 설정) while the page can act · no CTA band
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: Same order, with the offline class hidden: another member's money, a staff queue or staff analytics is never drawn from a warm copy offline (the offline state instead). 가입 심사's request another official answered is the helper's not found and offers 신청 목록; a malformed id offers 클럽으로 or 신청 목록.

Purpose. The officials' page for the club's own dues: one charge at a time, either a period (month, quarter, half year or year) of the one active recurring plan or one of the club's one-offs (한 번, owner decision 4), its rows as three groups of one row type (확인 대기, 미납, 완납), each ending in the one verb of that member, the balance line that opens the ledger, and the plan the charge is made from. Officials only (canManageDues). Built to flat-v2-list-a#du-ideal, du-month and du-once (L3-11).

Now. As before (gate, useDuesPage, buildRoster, silent writes with the page presenting the moment), with the plan generalised to a charge (dues-charges.ts): the head's Choice is 회비 선택 and its value is planId|periodStart; plansForChoice makes the Sheet's two groups (정기 회비: the recurring plan's periods newest first; 한 번: each active one-off by name with 9월 30일까지); chargeOf picks the charge on show (the pick, else the recurring plan's current period, else the nearest one-off, so a club with one-offs only opens on one; a stopped plan falls back). Only active plans are offered as choices: the plans read also lists a stopped one-off that still holds rows, to name those rows elsewhere. A one-off's head is its name; its rows are the dues rows whose planId is its own (the recurring plan also owns legacy rows with no plan) read by the key of the month it starts and its start day. A one-off with no rows (its charge was never made) keeps the head and offers one action, 회비 만들기, behind the Dialog 총회 회식비를 만들까요? that counts members and amount; it calls useGenerateDues with the plan id and no period (the server charges a one-off as of today). useGenerateDues and useDeactivateDuesPlan name errorSurface: 'dialog', so a failure is never a Toast; the calls are not silent (a success reads the toast 회비가 등록됐어요).

Anatomy. As before, with the head [SectionHead first] "총회 회식비" choice=[Choice 회비 선택 ▾] and [ValueSheet groups] 정기 회비 (months) · 한 번 (one-offs by name, line 2 the due day). A one-off shows the same tiles and sections as a month. No recurring plan and no one-off: the setup steps.

States. As before, plus: a one-off chosen (du-once); a one-off never charged (empty state with its action); a club with one-offs only; the Choice with one or two groups (a group title shows with two).

  • Offline (F1c): unchanged. A one-off's 확인 대기 queue is the same staff data: not drawn offline, a reported row reads 미납.

Actions. As before, with 회비 선택 → the charge Sheet (picking a value switches the charge on show) and 회비 만들기 (one-off never charged) → Dialog → useGenerateDues({ clubId, year, month, planId }).

Gaps. A one-off stopped from 회비 설정 leaves this list (only active plans are choices); its rows stay in the members' panes, named, but are no longer reachable here. 납부 취소 is offered only on rows of the month the charge starts in. ​

Club Detail — place surface (클럽 홈) ​

  • Route: /clubs/[clubId]
  • Screen: ClubDetailScreen — packages/features/clubs/src/club-detail-screen.tsx (MemberPage and VisitorPage under club-detail/*; five panes)
  • Shell: PlaceShell (CollapsingTopBar look="circles" + CollapsingHeaderLayer) · title club.name · bar actions (clubBarActions): a visitor 공유 alone; a member 공유 and the ⋯ (first 클럽 알림, then, for a member who can leave, the destructive 클럽 나가기; 신고 stays out); an official with manage access 공유 and the gear (name 관리, testID club.detail.admin-action, a badge dot while any door has a count, spoken 관리, 할 일 있어요) opening ClubManageSheet, whose 설정 group holds 클럽 알림. Offline the 클럽 알림 row is disabled (useIsOnline()), in both menus
  • Design: unreviewed
  • Verified: 2026-11-15
  • Band corner: the band's bottom-right corner holds one mark (bandCornerMark): the "1 / N" counter with two photos or more, the Door pill 사진 with exactly one, nothing with none; the board's FvkMediaBand draws a door only without a counter.
  • Batch 58 (ST1, 2026-11-13), Actions: Dismissing (the ×, the scrim, back) with a typed note, a changed 조 or a changed amount asks the edit guard (계속 수정, 그만두기); 그만두기 discards; with nothing typed it closes; while the write runs every way out is held.
  • Batch 58 (ST1, 2026-11-13), Actions: Dismissing (the ×, the scrim, back) with a typed note, a changed 조 or a changed amount asks the edit guard (계속 수정, 그만두기); 그만두기 discards; with nothing typed it closes; while the write runs every way out is held.
  • Batch 58 (ST1, 2026-11-13), States: QueryBoundary on the one PageStateView; a club the viewer cannot see is not found and offers 클럽으로 (the 클럽 tab). The 홈 pane's offline and error are OfflineEmptyState / QueryErrorState (compact); the 멤버, 기록 and 회비 panes take their loading, error and offline order from the same helper. Officials' counts (가입 신청 in the 멤버 pane, the 관리 sheet's doors, receipts waiting in the 회비 pane) are not drawn offline.
  • Notes: 2026-11-13 (UX-15): the visitor's band answers a membership offer. useOfferAnswer (club-detail/visitor/use-offer-answer.ts) reads this club's offer from the bulk read (useMyPendingClubOffer) and runs useAnswerClubNomination; joinBandOf takes offered and returns 수락 (primary) with 거절 as the band's secondary; the helper says the club's operators offered (no official's name). 2026-11-14 (batch 61): a club that is not there draws its NotFoundState (클럽으로) inside the back-only DetailShell, so the arrow stays.

Purpose. The club's page for everyone. A member reads five panes (홈, 채팅, 기록, 멤버, 회비) under a band, the club hero and Tabs; a non-member reads the same route as a visitor: the band, the hero, no Tabs, the public 소식 and the join action pinned by the club's 가입 방식. The separate 클럽 미리보기 route retired into this one (hub ruling C2).

Now (2026-11-10, W4 batch 14, L2-01). Rebuilt on the flat v2 kit (hub board HubPgClub, owner reviews 41, 46, 49, 50). The route takes tab (the pane, or admin for the 관리 sheet) and code (the invite code 초대 코드로 가입 resolved to this club). The 일정 pane retired into the 홈 feed (?tab=schedule opens 홈, LEGACY_TAB_MAP); the onboarding mission card, the feed chips, the schedule rows and the album banner are gone; the album banner is MediaBand with a photo pager and a "1 / 5" counter composed into media (ClubBandMedia), the identity header is ClubHero (name, up to three facts, the club's tagline as one cropped line), and the strip is the kit Tabs (5; the 홈 count is the unread posts, danger tone). use-collapsing-banner flips the bar solid at mediaBandCollapseAt and pins a member's Tabs under the solid bar (the header stops at maxHeaderTranslate); a visitor's whole header leaves. The 홈 pane is FeedList over the one feed model (domain/feed/*, useClubHomeFeed); the visitor's join flow is clubJoinGate → joinBandOf → one CtaBand + the 가입 신청 Sheet. (Batch 57 fix round: on a full club a waiting 가입 제안 keeps the offer line and the full Notice with the quiet 거절 only, no 수락; the gate reads the person's own application only, so a declined or cancelled offer never reads as 다시 신청하기.)

Anatomy.

├─ [PlaceShell] (bottom-inset-only; the band runs under the status bar)
└─ [ClubDetailBody]
    ├─ [QueryBoundary] queries=ctx.queries → loading=[MediaBand loading + ClubHero loading + SkeletonKind media ×3 + CollapsingTopBar (back)]; error / cold offline = the boundary's phases; !club → [NotFoundState] (클럽을 찾을 수 없어요 / 삭제됐거나 주소가 바뀌었어요)
    ├─ ▸ role.isMember → [MemberPage]
    │   ├─ [SegmentedTabs chrome="none" preload="visited"] items=[홈(count=unreadPostCount)·채팅·기록·멤버·회비]
    │   │   ├─ 홈 → [ClubHomePane] (below)   ├─ 채팅 → [ClubChatPane]   ├─ 기록 → [ClubRecordsPane]   ├─ 멤버 → [ClubMembersPane]   └─ 회비 → [ClubDuesPane]   (the last four take `ClubPaneProps`, contract F)
    │   ├─ [PageFrame] = [CollapsingHeaderLayer] → [ClubPlaceHeader]: [MediaBand ([ClubBandMedia]; door 사진 → album)] → ▸ [Notice] (보관된 클럽이에요) → [ClubHero] → 8 → [Tabs]  ·  [CollapsingTopBar look="circles"]
    │   ├─ ▸ 홈 pane and not archived → [ClubCreateFab]: 글 (a member; spoken 글쓰기) · 만들기 + [MenuSheet] 일정 만들기 / 행사 만들기 / 글쓰기 (an official who may schedule)
    │   ├─ ▸ hasManageDoors → [ClubManageSheet] (운영 and 설정 doors, 설정 = 테니스장 관리 · 클럽 대항전 · 성장 분석 · 클럽 알림 · 클럽 설정; counts from `useClubAdminSnapshot`, 클럽 나가기 destructive and last)
    │   ├─ [ClubNotifySheet] = `ClubNotifyLevelSheet` from @twomore/app ([ValueSheet] titled with the club's name; 전체 / 공지만 / 끔 with their meanings, the viewer's level chosen; opened by the ⋯ row or the 관리 sheet's 클럽 알림 door)
    │   └─ [InviteSheet] (the club's invite: opened by the header 공유 and the owner Notice)
    └─ ▸ !role.isMember → [VisitorPage]
        ├─ [ClubHomePane] viewer=visitor (public items only, no filter)  ·  [PageFrame] (no Tabs; ▸ Notice 정원이 다 찼어요 (attention) / 보관된 클럽이에요)
        ├─ [VisitorBand] = [CtaBand] by `joinBandOf({ gate, words, duesHelper, hasVerifiedCode, offered })`: with an offer (any gate but archived) primary 수락 + secondary 거절, helper 이 클럽의 운영진이 가입을 제안했어요 (and [OfferDeclineDialog], kit Dialog: 가입 제안을 거절할까요?, 닫기 · 거절, beside the band); otherwise by `clubJoinGate`: open 가입하기 · approval 가입 신청하기 · pending 신청 검토 중 (disabled, helper 총무가 승인하면 알려드릴게요) · rejected 다시 신청하기 · invite-only 초대 코드로 가입 (with a verified `?code=`: 가입하기); the dues ride the helper; full / archived pin nothing
        └─ [ApplySheet] 가입 신청: Field 자기소개 (optional, 500) + 신청 보내기; the server's 먼저 공개 일정에 참석해 주세요 is the Field's error line

홈 pane (ClubHomePane). FeedList with header: ▸ the owner's [Notice] 아직 멤버가 나뿐이에요 + 초대 링크 보내기 (an official in a club with one member) · ▸ [HomeCourtCell] (value Cell 홈 코트, opens routes.venue; absent without a home court) · [SectionHead 소식 + Choice ▾] (not for a visitor) → [FeedFilterSheet] (전체, 일정, 결과, 게시글, 공지, 자유, 경기 후기, 소개, 사진; 운영진 for an official). Items are ClubFeedItemRow (kit FeedItem) of the six kinds from the pure builders in packages/app/src/domain/feed/*: 일정 (map thumb, 내일 19:00 | 복식, seats, the viewer's 참석 Tag), 대회 (date, D-n 신청 마감), 행사, 게시글 (pinned notice first as 고정 공지, the author's face or photo, comments and hearts), 경기 결과 (get_club_results_feed: matches and top winner), 사진 (one item per uploader and day). A post's ⋯ opens the post [MenuSheet] (reactions, 공유, 공지 등록/해제, 게시글 삭제). Viewing a post marks it read at 60% viewability. States: loading (2 FeedItemSkeleton), error (소식), cold offline, empty (아직 소식이 없어요), archived (예정된 일정이 없어요, no FAB).

States. Loading / error / cold / not found as above; ?tab=admin (and the retired /clubs/[clubId]/admin route, a pure Redirect) opens the 관리 sheet once when the viewer has a door and the club context is ready; the retired /clubs/[clubId]/preview route is a Redirect to /clubs/[clubId] (a code stays the invite join); header collapse is scroll-driven with an a11y announcement on a pane switch; panes mount once headerHeight > 0.

  • Write failures (ruling EP-3): a visitor's application says its refusal on the apply Sheet's field, 가입 on the CTA band's helper; 클럽 나가기 (member page) and the feed's 공지 and 삭제 are told in a Dialog; never a Toast.

  • Offline (F1c, the app's root strip says it): offline with a cached club the page and its five panes stay under the root strip; the FAB, the ⋯ 클럽 알림 and 클럽 나가기 and the 입금했다고 알리기 band wait (the kit holds them); the gear's badge, the 관리 sheet's door counts (일정 관리's 입금 확인 included) and the 멤버 pane's 가입 신청 door are staff data and are not drawn offline; the 회비 pane's 회비 관리 door shows no count offline (club-stale, club-chat-stale, club-rank-stale, club-members-stale, club-dues-stale).

  • Membership offer (2026-11-13, UX-15): reading the offer (the band is not drawn) · answering (the primary shows its loading) · accepted (appRouter.replace(routes.club), the member's page, the hook's 가입했어요 toast) · declined (the Dialog closes, the offer leaves the cache, the ordinary band returns, toast 가입 제안을 거절했어요) · withdrawn meanwhile (nomination_not_pending → 이미 끝난 제안이에요 as the band's danger helper, offer re-read) · not the viewer's (nomination_not_yours) · full club (the Notice stays; accept is the server's to refuse: join_club_full) · archived (no offer band) · offline (the kit holds 수락 and the Dialog's 거절; the read pauses and the ordinary band shows).

Actions. A feed item → its page (feedItemRoute: session, event, post, album); the 홈 코트 Cell → 장소 상세; 글 / 만들기 → routes.clubBoardCreate / the MenuSheet's createSession, clubEventNew, clubBoardCreate; the gear → ClubManageSheet; 공유 (member) → the invite Sheet, (visitor) the OS share of the public page; ⋯ → 클럽 알림 (the level Sheet) or the leave Dialog (useLeaveClub); the 관리 sheet's 클럽 알림 door opens the same Sheet in place (no route); a pick → useSetClubNotifyLevel (set_club_notify_level), a landed write closes the Sheet, a failed one keeps it open with the failure line under the title (the hook's errorSurface is helper, never a Toast), and opening it again clears the line; the level governs that club's low-severity pushes only (공지만 keeps notices; payments, cancellations and requests are never muted); the visitor band per the gate (useJoinOpenClub, useApplyToClub, useJoinClub with the verified code, routes.joinClub); every growth-funnel step fires useRecordClubGrowthEvent (profile_view, join_tap, application_start, application_submit). 수락 → useAnswerClubNomination({ requestId, clubId, userId, accept: true }); 거절 → Dialog, then { accept: false }. A refusal is the band's helper (errorSurface: 'helper'), never a Toast.

Reads. useClubContext (the club bundle get_club_detail_bundle, trimmed of the mission fields in 00723), useClubPosts, useClubEvents, useClubResultsFeed (get_club_results_feed, 00723), useSessionCardModels, useProfilesByIds (one bulk read for every name), useClubMediaSignedUrls; a visitor: useClubSessions (public rows by RLS), useApprovedPublicClubMedia, useClubDiscoveryStatsQuery (the member count for the gate and the hero), useMyJoinRequest; a member: useMyClubNotifyLevels(userId) (the bulk {clubId: level} map; a club with no entry reads 전체) and useIsOnline.

회비 pane, one-offs (F2 cut 1). A one-off is drawn as the board's club-dues-once: the head is the plan's name, the count 한 번만, the tiles 낼 금액 and 9월 30일까지 with D-day (or N일 지남), then 입금 계좌; paid reads 완납 over 낸 금액 and 낸 날, no history. A month and a one-off both open (club-dues-two): the charge due soonest leads, the recurring one on the same day, then the earlier period (a member pays the head charge first) and the Cell 미납 reads 2건. A stopped one-off that still holds the member's row is in the plans read (active: false) and keeps its name; a row with no plan at all that does not span whole calendar months (legacy) reads 회비 and 한 번만; a whole-month row without a plan keeps the months it spans as its title. club-dues-session (일정마다) is retired from the board and never built.

Gaps. ⋯ 신고 has no model yet (hub ruling C8; 신고 stays out of the menu by the owner's decision); the post commands ride the item's ⋯, not a long press, because FeedItem has no long-press handler; an event's seat count is not read (the event item shows none); the pinned Tabs and the solid-header hairline (StickyEdge) are not yet visually verified on device.


Join Review Detail (가입 심사) ​

  • Route: /clubs/[clubId]/join-review/[userId]
  • Screen: JoinReviewDetailScreen — packages/features/clubs/src/join-review-detail-screen.tsx (the reads and the gate join-review/use-join-review.ts, join-review-model.ts; the body review-body.tsx; the plain states review-states.tsx)
  • Shell: DetailShell · title publicClubProfile.applyPanelTitle (가입 신청, the table's word for the ones to answer) · sub the club's name · footer CtaBand (pair)
  • Design: unreviewed
  • Verified: 2026-11-15
  • Batch 58 (ST1, 2026-11-13), States: Same order, with the offline class hidden: another member's money, a staff queue or staff analytics is never drawn from a warm copy offline (the offline state instead). 가입 심사's request another official answered is the helper's not found and offers 신청 목록; a malformed id offers 클럽으로 or 신청 목록.
  • Notes: 2026-11-13 (N13, a person becomes a member only by their own act): a request whose source is admin_nominated has nothing for an official to approve. The band draws one tonal control, 제안 취소 (a Dialog that names the person and says it can be sent again, then { accept: false, withdrawsOffer: true }), and its helper says joinReview.offerWaiting (답을 기다리고 있어요); the server's refusal decide_nominee_must_accept reads joinReview.nomineeMustAccept. The nominee's own side is the next batch. Rebuilt W4 batch 19 to flat-v2-detail#jr-ideal (L3-32). The member-vote frames (jr-vote, jr-voted, jr-veto) are cut (ruling P1): no vote store exists, and 가입 승인 is the static 운영진 승인 Cell of 클럽 설정.

Purpose. The officials' dossier on one applicant: who they are, what they said, how they have played with the club. A task page that leads with the person; ends in the one genuine binary decision, 거절 · 승인.

Anatomy.

├─ [DetailShell] title="가입 신청" sub=club name onBack footer=[CtaBand secondary=거절 primary=승인]
├─ [Block kind="identity"] face, name + Level tag, facts (구력, 게스트); the door 신뢰 66점 (or 신뢰 3/5회) → routes.profileTrust(applicant)
├─ [SectionHead first "가입 신청"] → [CellGroup]: 지금 (게스트), 신청한 날 → what they wrote (AutoLinkText, reading role)
└─ with history only: [SectionHead "클럽 활동"] → [CellGroup]: 참석 횟수, 첫 참석, 마지막 참석, 받은 매너
    → [Shelf cell "함께 친 멤버"] → [Shelf cell "추천"]   (a face opens 공개 프로필; an empty Shelf draws nothing)

States. Loading (the Block and the dossier, each its own skeleton) · cold offline (OfflineEmptyState) · wall (a member: EmptyState permission, no band) · gone (another official answered first: EmptyState notfound, one way on 신청 목록 → replace(routes.clubMembers)) · error (QueryErrorState, one way forward) · deciding (both buttons wait, 승인 reads 승인 중...). The wall comes before the application's reads settle, so a member never meets an error from a read the server refuses. A section with nothing in it is not drawn.

  • Write failures (ruling EP-3): a refused decision is the CTA band's danger helper on this page; never a Toast.

  • Offline (F1c, the app's root strip says it): the application is staff data; offline the page is the offline state even when it was read this session (jr-hidden).

Actions. 승인 → useDecideJoinRequest({ accept: true }) at once; 거절 → a Dialog naming the person ("신청자에게 알려 드려요"), then { accept: false }; both onSuccess → appRouter.back(). The success Toast is the hook's.

Gaps. 경기 결과 and ELO (the board's 클럽 활동 Cells) and 칭찬해 준 사람 wait for a read: the dossier summary has no match record and counts praise tags, not people. The Cells show 받은 매너 (the tag total) instead. The hook's success Toast does not name the applicant (the board's does). ​

Club Members (멤버 관리) ​

  • Route: /clubs/[clubId]/members
  • Screen: ClubMembersScreen — packages/features/clubs/src/club-members-screen.tsx (the page object club-members/use-members-page.ts, its reads use-members-reads.ts, writes use-member-writes.ts, the rows as data members-model.ts, the list members-list.tsx, the rows member-row.tsx · applicant-row.tsx · guest-row.tsx, the Sheet member-sheet.tsx, the overlays members-overlays.tsx · member-dialogs.tsx, the guest roster guest-roster-section.tsx and guests/*)
  • Shell: DetailShell · title common.manageMembers · sub the club's name · scroll={false} · the ⋯ (MenuSheet: 클럽 단톡방 만들기 → routes.dmCreateGroup, 출석 현황 → routes.clubAttendance)
  • Design: unreviewed
  • Verified: 2026-11-15
  • Offer rows: a waiting offer's Tag 답을 기다리고 있어요 is the row's extra (under the facts), so the title line holds the name and the one control 제안 취소, in both the shortlist row and the 보낸 가입 제안 row.
  • Batch 63 (L8, N4), Anatomy: the 가입 신청 head counts and lists only the people who asked to join; the club's sent offers are their own group, 보낸 가입 제안 (membersPage.offersSentHead), right below it and shown only while an offer waits (members-model.ts: applicantSection, offerSection). The guest row's 초대함 stays a fact about that row.
  • Notes: 2026-11-14 (batch 61): the priority list has its own sub-heading (the lead's ruling L23); the guest block sits on the rail.
  • Batch 58 (ST1, 2026-11-13), States: The app's one PageStateView in the page's Header: offline (nothing cached) wins over error, error over loading, then not found (offers 클럽으로, the 클럽 tab), then the wall (the helper's no-permission). A warm copy shows offline. 멤버 관리 does not draw the 가입 신청 queue or the guest roster offline.
  • Notes: 2026-11-13 (N13): after 가입 확정 a guest stays in the 가입 제안 list (the guest answers for themselves); a row with a pending nomination reads joinReview.offerWaiting first and keeps only the 제안 취소 button (it asks first, then the decide path with accept false; the request id travels with the row), and a nominated request has no 승인 in 가입 심사 or in 멤버 관리's 가입 신청 queue (an offer row: 답을 기다리고 있어요 and 제안 취소). The nominee's own row and answer are the next batch. Rebuilt W4 batch 19 to flat-v2-list-a#mb-roster (L3-25), absorbing the member Sheet (L3-26, ruling P2): a member row opens the Sheet, whose name row opens 공개 프로필 and whose 멤버 기록 opens that member's 신뢰 기록 (routes.profileTrust(userId)); 출석 현황 lives in the ⋯. 2026-11-13 (UX-15): a waiting offer says it in the row's status slot through OfferWaitingTag (shared/offer-waiting-tag.tsx, the waiting meaning, 답을 기다리고 있어요) in BOTH the 가입 신청 queue and the 게스트 명단's shortlist, and keeps one quiet control, 제안 취소 (a write, held offline; it asks first). The guest shortlist is 가입 후보 (row action 후보 추가); the step that sends the offer is 가입 제안 보내기. A declined offer leaves an ordinary guest; an accepted one converts the guest. The fourth offer to one person in a week is refused (membership_offer_rate_limited) and the roster's failure Dialog says joinReview.offerRepeatLimited. 2026-11-15 (batch 66, MEM-6): a guest the viewer blocked shows the quiet line joinReview.offerBlockedByMe (차단한 사용자예요) in place of 초대 (board frame mb-blocked), and a shortlisted guest the viewer blocked shows it as a Tag with offerBlockedByMeReason under the facts, keeping only 삭제 (no 가입 제안 보내기); the viewer's blocks are ONE bulk read for the page (useBlockedUsers via use-blocked-by-me.ts; the pure offerStateOf decides available | blockedByMe; a list still loading leaves the button). A person who blocked the official is left out of the suggestions (get_recommendable_guests, 00777). A server refusal offer_unavailable is a statement: the Dialog's title is 지금은 이 분께 보낼 수 없어요 and its body a reason class naming no block (offerUnavailableReason).

Purpose. The club's people for the officials, one list of one row type: the applications waiting first, then the roster by role and the guests worth inviting; one Choice filters it and the FAB invites. A member never reaches it (they read the roster as 클럽 홈's 멤버 pane): they meet the wall.

Anatomy.

├─ [DetailShell] title="멤버 관리" sub=club name onBack scroll=false actions=[⋯]
├─ [FeedList] header 12 → heads and rows (FeedList takes no footer; the roster is its last item)
│   ├─ [SectionHead first "가입 신청" count choice=[Choice 전체 ▾]] → [PersonRow applicants] 승인 · 거절 in the row; the row → 가입 심사
│   ├─ [SectionHead 대표 / 총무 / 경기이사 / 멤버 (expand)] → [PersonRow members]: line 2 = 조 (+ (직접 지정)) then 신뢰 점수; Tag 출석 주의
│   ├─ [SectionHead "추천할 만한 게스트"] → [PersonRow suggestions] with the sm Button 초대, then the Tag 초대함; the row → 공개 프로필
│   └─ (a club with guests, for a manager of members) [SectionHead "게스트 명단"] → [GuestRosterSection] (가입 후보 rows: 가입 제안 보내기 · 삭제; once sent the row is the offered row: the waiting Tag and 제안 취소; then the 우선순위 명단 under its own sub-heading 우선순위, the same component and level as 가입 후보)
├─ [Fab 초대] → [InviteSheet] (the club's invite Sheet, `settings/join-group`)
└─ overlays: [PersonSheet 멤버] (name row, 멤버 기록, 역할 변경, 조 지정; 내보내기 last) → [ValueSheet 역할 변경] → [Dialog] · [ValueSheet 조 지정] · [Dialog 내보내기] · [Dialog 거절] · [ValueSheet 멤버 보기 (전체, 가입 신청, 출석 주의)]

States. Loading (one person skeleton) · cold offline · error (QueryErrorState) · wall (not an owner or admin) · ready · stale (offline with what was read: the offline strip, no 승인 · 거절 · 초대, no FAB, no ⋯; the Sheet keeps only 멤버 기록) · filtered (출석 주의 keeps the flagged people under their roles; 가입 신청 keeps the applicants alone; none: one empty kind with 전체 보기) · 멤버 folded until opened. Under two 조 the 조 fact leaves.

  • Write failures (ruling EP-3): the five member writes and the guest roster and invite modal tell a failure in one Dialog in the app's words (clubs.refusal), 닫기 forgets it; never a Toast.

  • Offline (F1c, the app's root strip says it): offline the roster stays under the root strip (mb-stale); the 가입 신청 queue (and its filter value) is staff data and is not drawn; the FAB 초대, the ⋯ 클럽 단톡방 만들기, the guest 초대 buttons and a member's edits wait.

Actions. 승인 → useDecideJoinRequest at once; 거절 → Dialog naming the applicant; 초대 → useNominateForMembership (the guest becomes a pending request, and the row's Button the Tag 초대함); 역할 변경 → value Sheet → Dialog naming the person and the role → useUpdateMemberRole (the 대표's role is not offered); 조 지정 → useAssignMemberGroup (자동 clears the pin); 내보내기 → Dialog → useRemoveMember (not offered for the 대표); pull-to-refresh.

Gaps. The 게스트 지명 search entry (NominateGuestModal) has no board home and is retired: the guests worth inviting are the nomination path. The success Toasts are the hooks' (they do not name the person). The guest roster (게스트 명단, batch 2) keeps its v1 pieces (AddCard, Text) until its own board. The ⋯ is drawn when the page is ready, not in the loading, error and offline frames. ​

Partner Suggester (복식 파트너 추천) ​

  • Route: /clubs/[clubId]/partners
  • Screen: PartnerSuggesterScreen — packages/features/clubs/src/partner-suggester-screen.tsx (the list and its states are partners/partner-feed.tsx, the two Sheets partners/partner-sheets.tsx, the sentences partners/partner-copy.ts, pure)
  • Shell: DetailShell · title common.doublesPartnerSuggestions · sub the club's name · scroll={false} · headerRight: none
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: PageStateView for loading, error and offline; a club that cannot be seen is not found and offers 클럽으로 (a real gap, now closed); the suggestions' own list slots keep the list's cold flag.

Purpose. The club's members ranked as doubles partners for the viewer, best fit first, each with its number (86점) and the fact behind it. Entered from the 멤버 pane's row until L2-01's batch moves the entry to 기록's 최고의 파트너 head (R7); the 대진 flow's entry has no page code yet. Built W4 batch 5 to flat-v2-list-a#pt-ideal (L3-24).

Anatomy.

├─ [DetailShell] title=common.doublesPartnerSuggestions sub=club.name onBack scroll=false
├─ [FeedList] perfLabel=partner-suggester contentContainerStyle=SCROLL_CONTENT.list · pull to refresh
│  ├─ ▸ item (the whole roster, best fit first) → [PersonRow] app, context=suggestions
│  │     ├─ (first row) 12 spacer · [SectionHead] first title=추천 순서 info={term: 추천 점수 → spoken "추천 점수 안내"}
│  │     ├─ name, face, line 2 = the strongest factor's fact, figure "86점" (44 wide), level = the NTRP numeral only where a run of the same level starts (null holds the slot)
│  │     ├─ spoken "1위, 이서연, 86점, NTRP 3.5, 최근 20회 중 19회 참석"
│  │     └─ press → the Score Sheet
│  ├─ ▸ loading → 24 spacer + [SkeletonKind] person × 6 (no head)
│  ├─ ▸ empty → [EmptyState] kind=first glyph=Users 추천할 파트너가 없어요 · 클럽에 멤버가 더 모이면 다시 확인해 보세요
│  ├─ ▸ error (any of the four reads) → [EmptyState] kind=error what=추천 · retry refetches the reads asked
│  └─ ▸ cold offline → [EmptyState] kind=offline · retry
├─ [ScoreSheet] 추천 점수 (a row opened) — read-only
│  ├─ [PersonRow] name, face, level (always), last · press closes the Sheet, then opens 공개 프로필 (with the press-in prefetch)
│  ├─ [Strip] tiles lead · 총점 / "86" + "점 / 100점"
│  ├─ four two-line [Cell]s: 실력 차이 36/40 · 함께 친 기록 23/30 · 신뢰 13/15 · 출석 14/15, each with its facts on line 2 joined by " · "
│  └─ [CaptionRow] attach=object 합계 "36 + 23 + 13 + 14 = 86"
└─ [ExplainerSheet] 추천 점수 안내 (the "?" opened it) — [SheetGroupTitle] 100점을 이렇게 나눠요 · four ink Cells with their maximum at the right · [CaptionRow] 친목 경기와 부전승은 계산에서 빼요

The score (packages/app/src/domain/rules/partner-matching.rules.ts, pure). 100 = 실력 차이 40 (equal ELO 40, 400 apart 0) + 함께 친 기록 30 (the pair's win rate as teammates × 30, pulled toward 15 below ten decided matches, 15 with none; a draw counts to neither side) + 신뢰 15 (the trust score ÷ 100 × 15) + 출석 15 (attended ÷ counted of the last 20 × 15). A candidate with no trust score or no recent form (under five sessions, or someone the viewer shares no club with) scores a neutral 8 of 15 on that factor and says so on the Sheet (신뢰 기록이 없어요 · 기본 8점, 참석 기록이 없어요 · 기본 8점; 함께 친 적이 없어요 · 기본 15점 for chemistry). The whole roster is ranked (no cap); a tie keeps the roster's order. partnerScoreFacts(candidate, viewerElo) returns the row's fact and the Sheet's four facts as data from the same inputs as the points, so a row and its Sheet cannot disagree; partner-copy.ts writes the sentences from the i18n. The row's fact is the strongest factor's: the factor with the most points against its own maximum among those that rest on a record (a neutral factor never headlines), ties in the weight order. N경기 is decided matches (wins + losses); a draw is not one.

Reads. usePartnerCandidates(clubId, userElo): the club roster (useClubProfiles), the viewer's partner records (usePartnerRecords: doubles and mixed, every club), the roster's trust scores (useTrustScores, migration 00703) and their recent forms (useTrustRecentForms, migration 00712, attendedOf20 / countedOf20, null below five sessions or for a viewer without context), the last two in bulk and chunked by 100 (Protocol A). Joined by rankPartnerCandidates in a memo; the lifecycle is useCompositeNetwork over the reads asked (the bulk reads are not asked of a roster of one). Any one failing is the error state. The viewer's ELO is profile.doublesElo ?? eloRating ?? DEFAULT_ELO.

States. loading / empty / error / cold offline / ranked / Score Sheet (matches, few matches, none yet, no trust or attendance record) / rules Sheet, each with a screen test.

Actions. Back · pull to refresh · a row → its Score Sheet · "?" after 추천 순서 → the rules Sheet · the Score Sheet's person row → 공개 프로필. A Sheet never opens over a Sheet, so the Score Sheet carries no door to the rules.

Facts are counts. A fact about a person's conduct is a count and never a percentage (신뢰 84점; 최근 N회 중 M회 참석, N the sessions counted, 20 at most); the one percentage is the pair's win rate.

Gaps.

  • The same score for 기록's 최고의 파트너 (a pair that includes the viewer opens the other member's score) and the 대진 flow's entry are L2-01 / the match-generation flow's; no page-links row exists for the 대진 flow.
  • The Score Sheet's facts joined on one line: the Cell's line 2 is a string, so the board's facts-with-breaks render joined with " · " until the Cell takes facts.
  • Orphaned words in partnerSuggester: breakdownElo, breakdownChemistry, historyLabel, noHistory, reasonEloProximity, reasonTrust, reasonAttendance (the dead-keys check lists them; the lead prunes). The Cell labels 신뢰 and 출석 read the existing breakdownTrust and breakdownAttendance. entrySubtitle stays for the 멤버 pane's row.

Club Reports (신고 관리, L3-28) ​

  • Route: /clubs/[clubId]/reports
  • Screen: ClubReportsScreen — packages/features/clubs/src/club-reports-screen.tsx (route only; the page and its parts under club-reports/*)
  • Shell: DetailShell · title t().common.manageReports ("신고 관리") · sub club.name · headerRight: none · scroll=false
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: Same order, with the offline class hidden: another member's money, a staff queue or staff analytics is never drawn from a warm copy offline (the offline state instead). 가입 심사's request another official answered is the helper's not found and offers 신청 목록; a malformed id offers 클럽으로 or 신청 목록.
  • Notes: 2026-11-11 (F4, decision 12, boards rp-content, rp-content-deleted): a report about club content names its subject (post, comment, session, chat message); the Sheet carries a 내용 row under the reason. The subjects come from ONE bulk read, useReportSubjects(clubId, reportIds), joined to the reports by report id in report-rows.ts (contentOf); a report with no subject row draws the person alone. 2026-11-10 (W4 batch 21b, board flat-v2-list-a#rp): rebuilt from one flat list with a 해결 처리 Button on each row to two sections (미해결, 해결됨) whose rows have one tap, the report's Sheet; the report is resolved with its OUTCOME, 확정 (upheld) or 기각 (dismissed) (rulings C1, C2). The page never reads, shows or counts who reported (reporterId is no longer read here).

Purpose. The officials' queue of reports filed against the club's members: read each report whole and resolve it as 확정 (it takes points off the person's 신뢰 점수 for a window) or 기각 (it counts for nothing).

Anatomy.

├─ [DetailShell] title=신고 관리 sub=club.name onBack scroll=false
├─ ▸ cold offline → [OfflineEmptyState] · read failed → [QueryErrorState] · club missing → [NotFoundState]
├─ ▸ not an official (canManageClub false) → [EmptyState kind=permission] 운영진만 볼 수 있어요 / 신고 내역은 클럽 운영에 쓰는 화면이에요 (no button; the reports are not read)
├─ ▸ loading → 12 + [SkeletonKind person ×5] · empty → [EmptyState kind=first] Flag 들어온 신고가 없어요
└─ ▸ ready → [FeedList] header 12
   ├─ [SectionHead first] 미해결 (N건) + [ReportPersonRow]×N   (reported person; line 2 "9월 17일 신고" + category; the reason quoted, 2 lines then it clamps)
   ├─ [SectionHead] 해결됨 (N건) + [ReportPersonRow]×N        (trailing Tag 확정됨 / 기각됨; 해결됨 alone for a report resolved before outcomes existed)
   ├─ ▸ a row pressed → [ReportSheet] 신고: the person's name row (→ 공개 프로필), 사유 and the reason whole,
   │    ▸ a report that names its subject → [SheetGroupTitle] 내용 + [CellGroup] one [Cell]:
   │        live post / comment / session → variant value, label 게시글 / 댓글 / 일정, value 보기, chevron = the door
   │        deleted → static, label 삭제된 게시글 / 삭제된 댓글 / 삭제된 일정, no door
   │        chat message → static, label 채팅, no door and no deleted state
   │    footer [ButtonPair equal] 기각 | 확정 (an open report only; a resolved report's Sheet has neither the reason nor 내용)
   ├─ ▸ 확정 pressed → [Dialog] "{name}님의 신고를 확정할까요?" / "확정하면 {months}개월 동안 신뢰 점수에서 {points}점이 빠져요" (`useReportUpheldPolicy`), 닫기 | 확정
   └─ ▸ a refusal → [Dialog] the named reason, 닫기 alone

States. Cold offline never reads as "no reports"; the 확정 button waits for the policy read and a failed policy read says so under the buttons and holds it; a policy whose points are 0 says 확정해도 신뢰 점수는 달라지지 않아요; a resolved report's Sheet has no footer and no reason (the outcome is its Tag); the Dialog holds while the write is in flight (committing) and the scrim does not dismiss it. Refusals: report_already_resolved (the list is read again), a 42501 (not an official of the report's club), anything else as a general failure.

  • Offline (F1c, the app's root strip says it): the report queue is staff data; offline the page is the offline state even when it was read this session (rp-hidden). The subjects read is part of the page's read (cold offline and a failed read stand for the whole page).
  • A report with no subject row (a profile report, one filed before 00745) has no 내용 row; a deleted subject reads 삭제된 ... static (P4: no excerpt is kept); report_self (P6: a report about the viewer; the server hides the row and refuses the decision) is the page's failure Dialog 내 신고는 처리할 수 없어요 / 나에 대한 신고는 플랫폼 운영팀이 처리해요, never a Toast.

Actions. Row → the report's Sheet; 기각 → useResolveMemberReport({ reportId, clubId, outcome: 'dismissed' }) at once; 확정 → the Dialog → outcome: 'upheld'; the name row in the Sheet → routes.userProfile; the 내용 row's door → routes.clubPost(clubId, postId) for a post and for a comment (the post page has no comment anchor, so a comment opens its post), routes.clubSession(clubId, sessionId) for a session; it only navigates, and the decision stays about the person (nothing hides or deletes content from the Sheet); pull to refresh. Entry from the 관리 sheet and from 알림's 신고 row is unchanged.

Gaps. useResolveMemberReport still shows a toast for a failure besides this page's Dialog until its errorSurface is set to 'dialog' (unchanged, not in this batch). A comment's door lands on the post, not on the comment. A chat message has no door (the thread is not the official's to open). When the only official is the reported person, the report waits for the platform queue (P6). A failed subjects read shows the page's error with a retry, not the persons alone. A report from a public profile or a 1:1 chat stays in the platform queue (content_reports), which has no in-app review screen.



Club Sessions (일정 관리, L3-27) ​

  • Route: /clubs/[clubId]/sessions
  • Screen: ClubSessionsScreen — packages/features/clubs/src/club-sessions-screen.tsx (route only; the page and its parts under club-sessions/*)
  • Shell: DetailShell · title clubDetail.adminTab.sessions.title (일정 관리) · sub club.name · headerRight: none · scroll=false
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: The app's one PageStateView in the page's Header: offline (nothing cached) wins over error, error over loading, then not found (offers 클럽으로, the 클럽 tab), then the wall (the helper's no-permission). A warm copy shows offline. 멤버 관리 does not draw the 가입 신청 queue or the guest roster offline.
  • Notes: 2026-11-10 (W4 batch 21b, board flat-v2-list-a#cs): rebuilt from the three SegmentedTabs panes to one list with one Choice in the first Section head (a detail page has no Tabs). The rows are SessionFeedItem (the officials' deciding fact is attention, a plain-text fact after the time: 게스트 N명 from the confirmed RSVPs whose joinedAsMember is false, one bulk useSessionsRsvps); CtaBand became the FAB. 2026-11-11 (F4, decision 15): one bulk read at parent scope, useSessionManageCounts(ids) (get_session_manage_counts, 00747), gives each row 입금 확인 N건 (the Feed item's state Tag, meaning task; it takes the state slot before the viewer's own and the session's state; 게스트 N명 stays a plain fact beside it) and, for a finished session, N경기 as the fact (it replaces the guests tally). Zero, NULL (no confirmation authority) or no entry draws nothing. The same-court row trails the task Tag, else the games, else the guests Tag, else the seats. A non-member reader of a public session gets no row; the count excludes the caller's own submitted row, so it need not equal get_club_admin_snapshot's figure. A member who is not an official meets the permission EmptyState (the board has no frame for it).

Purpose. The officials' list of the club's sessions: 예정 (모집 중, 마감 and 진행 중 together, by day, a live session first in its day) or 지난 일정 (finished, newest first, paged), and the one way to add another (the FAB).

Anatomy.

├─ [DetailShell] title=일정 관리 sub=club.name onBack scroll=false
├─ ▸ cold offline → [OfflineEmptyState] (the Header alone: no head, no FAB)
├─ ▸ club read failed → [QueryErrorState] · club missing → [NotFoundState]
├─ ▸ not an official (canManageSchedule false) → [EmptyState] kind=permission — 운영진만 볼 수 있어요
└─ ▸ official (and while the club loads, without the FAB)
   ├─ [FeedList] header = 12 + [SectionHead first] 클럽 일정 (or the one court's name) + [Choice chevron] 예정 | 지난 일정 (selected)
   │  ├─ per day: [SessionDayHeader] + [SessionFeedItem]×N   (rows share one court → [MediaRow kind=sameCourt]: time, format, level, trailing = [Tag task] 입금 확인 N건 · N경기 (finished) · [Tag] 게스트 N명 · 6/8; the Feed item line: state Tag 입금 확인 N건 (task) over 호스트/참석/마감, facts: time, then N경기 (finished) or 게스트 N명)
   │  ├─ ▸ loading → [FeedItemSkeleton who n=5] · empty → [EmptyState kind=first] · list read failed → [QueryErrorState]
   │  └─ ▸ next page of 지난 일정 → footer [FeedItemSkeleton who n=2]
   ├─ [Fab] 일정 (spoken 일정 만들기) placed=plain — canManageSchedule
   └─ ▸ Choice open → [ValueSheet] 일정 보기: 예정 (모집 중과 진행 중인 일정) · 지난 일정 (끝난 일정)

States.

  • Offline (F1c, the app's root strip says it): offline with cached rows the page stays under the root strip; the FAB and write rows wait; cold offline is the offline state. The fee count is a staff read (root sessionManageCounts is persist-denied and the row draws no 입금 확인 while offline); N경기 is a fact and keeps showing.

  • Loading (no cache): the head over five skeleton Feed items; the FAB stays for a known official, and is absent while the club and the role have not arrived. Cold offline: the Header over OfflineEmptyState, never "no sessions". A failed list read: the head over QueryErrorState.

  • Empty 예정: 예정된 일정이 없어요 / 새 일정이 올라오면 여기에 보여요, no button (the FAB is the action); empty 지난 일정: 아직 완료된 일정이 없어요.

  • Every row sharing one court (two rows or more) names it once as the head's title and leads with the time.

  • The cold-offline frame draws the FAB on the board; the page draws none, since the role is unknown (an official is not known to be one).

Actions.

  • Row → routes.clubSession(clubId, sessionId); FAB → routes.createSession(clubId); the Choice → the value Sheet (a tap selects and closes); pull to refresh; 지난 일정 loads the next page near its end.

Gaps.

  • The counts read memory only: a cold offline start draws neither 입금 확인 nor N경기, because the one read carries both and its root is persist-denied. A payer's 입금 완료 reaches the list within two minutes, on a confirmation, a transfer report, a score and pull-to-refresh.
  • The day headers of 지난 일정 read the date alone: dayHeaderQualifier returns nothing for a past day (the board draws 어제, 3일 전).
  • Browser/native acceptance remains pending.


Club Settings (클럽 설정) ​

  • Route: /clubs/[clubId]/settings
  • Screen: ClubSettingsScreen — packages/features/clubs/src/club-settings-screen.tsx (the page and its groups under settings/*)
  • Shell: DetailShell · title t().clubSettingsScreen.pageTitle · sub club.name · no footer (the archived receipt's CtaBand excepted)
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: An archived club's owner or member meets the recovery receipt, not the wall.
  • Batch 58 (ST1, 2026-11-13), States: The app's one PageStateView in the page's Header: offline (nothing cached) wins over error, error over loading, then not found (offers 클럽으로, the 클럽 tab), then the wall (the helper's no-permission). A warm copy shows offline. 멤버 관리 does not draw the 가입 신청 queue or the guest roster offline.
  • Notes: 2026-11-02 (W4 batch 2, L4-20, flat-v2-forms#club-settings). The page is rebuilt to its board: nine groups of Cells under Section heads (사진, 모집 프로필, 정보, 가입, 회비, 활동, 모집 계획, 출석, and for the owner 주의가 필요한 설정). Every value is a value Cell that opens its own Sheet (Edit Sheets commit="save" for the texts, 수준 범위, 정원, 방문 횟수 제한 and 게스트 참가비; Sheets of radio rows for 성격, 구성, 종목, 사진 정책, 예상 출석률, 가입 방식), a Switch writes at once, and every value write is silent and inline (ruling S5): useSilentClubUpdate calls useUpdateClub().mutateAsync(vars, { silent: true }) (SilentMutateOptions exists on mutateAsync only, not on mutate), lays the patch over the club while it is in flight, and on a failure puts the value back and names the row (failedKey) that then carries common.saveFailed as its error line (an Edit Sheet shows it above its button and stays open). The old EditFieldModal, SelectionChip rows, DefaultCoverPicker, the 운영 group (its 일정 만들기 shortcut is 관리's), the guest roster (now the last group of 멤버 관리, L3-25), and the three lists (정기 일정 L3-48, 조 L3-49, 앨범 관리 L3-50 are their own routes) are gone from this page. The numbers a 모집 계획 implies (필요 정원, 주간 연인원, expected headcount per meet) left with the old planning section: 성장 분석 is the door. 가입 승인 reads 운영진 승인 as a static Cell under 승인 후 가입 only (the join-vote rules are not built). 활동 지역 is a door to 테니스장 관리 (the legacy free-text district editor is gone). 사진 찍기 joins the banner and logo menus with the native camera permission (W5).

Purpose. Active-club configuration for callers with canManageClub (flat-v2-forms#ks-ideal). An archived club instead shows its recovery receipt to its actual owner or member (ks-archived, ks-archived-member), without exposing the editing groups.

Anatomy.

├─ [DetailShell] title="클럽 설정" sub=club.name onBack keyboardShouldPersistTaps="handled"
├─ gate (generic): cold offline → [OfflineEmptyState] · loading → [SkeletonKind line + cell ×4] ×2 · error → [QueryErrorState] · club null → [NotFoundState] · !canManageClub → [EmptyState kind=permission] (운영진만 바꿀 수 있어요)
├─ ▸ archived + owner/member → [ArchivedClubPage]: [Notice meaning=deadline] (대표가 …까지 복구할 수 있어요; a neutral Notice when the 30 days are over) · [SectionHead 보관한 클럽] · [CellGroup] static 이름, 보관일 · ▸ owner inside the window: footer [CtaBand] 클럽 복구하기 → Dialog 클럽을 복구할까요?
└─ ▸ ready → [ClubSettingsPage]
    ├─ [PhotosGroup] — [MediaRow 배너] and [MediaRow 로고] (tap → [MenuSheet]; a spinner on the uploading row, the other row held) · [MenuSheet 배너]: 앨범에서 고르기, 기본 사진 선택 (→ [Sheet bleed] of [CoverGrid], every default cover), destructive 배너 삭제 only for an upload (→ Dialog) · [MenuSheet 로고]: 앨범에서 고르기, destructive 문양 쓰기 only for a photo logo (→ Dialog)
    ├─ [ProfileGroup] 모집 프로필 — static 프로필 완성도 (5/7, the missing items on line 2) · task 앨범 관리 (pending-photo tally) → L3-50 · value 사진 정책
    ├─ [InfoGroup] 정보 — value Cells 이름, 한 줄 소개, 소개, 성격, 구성, 종목, 수준 범위, nav 활동 지역 → L3-29, value 코트 정보
    ├─ [JoinGroup] 가입 — value 가입 방식 (meaning on line 2 in its Sheet) · ▸ static 가입 승인 (승인 후 가입 only) · value 초대 코드 (the members-only read `useClubInviteCode`, 00711; a failed read is the row's error line) (→ [ClubInviteSheet] once the code is read, 공유하기) · Switch 클럽 찾기에 공개
    ├─ [DuesGroup] 회비 — nav 회비 설정 (duesSummary) → L4-14 · nav 입금 계좌 → L4-21 · Switch 호스트 참가비 면제
    ├─ [ActivityGroup] 활동 — nav 정기 일정 (count) → L3-48 · nav 조 (count) → L3-49 · Switch 게스트 허용 · ▸ value 게스트 참가비, 방문 횟수 제한 (only while 허용)
    ├─ [PlanningGroup] 모집 계획 — value 정원, value 예상 출석률, nav 성장 분석 → L3-03
    ├─ [AttendanceGroup] 출석 — Switch 불참 제한 · Switch 취소 페널티 (always drawn, held while the limit is off)
    ├─ ▸ [DangerGroup] 주의가 필요한 설정 (owner) — command 대표 권한 넘기기 (→ [TransferOwnershipModal]) · destructive 클럽 보관 (→ Dialog, then replace to L1-02)
    └─ [SettingsSheets] — the Edit Sheets and value Sheets above, side by side; the page keeps the name of the one that is open
[TransferOwnershipModal] bleeding Sheet 새 대표 선택: candidates as picker PersonRows grouped by role under Section heads with counts; a tap opens the Dialog naming the recipient

States. The board's 12 page states (ideal, official, member wall, uploading, 게스트 허용 off, 바로 가입, invite, loading, cold, error, archived, archived member) and its 17 overlays are one test each (__tests__/club-settings-screen.test.tsx, club-settings-overlays.test.tsx), plus the generic states (not found, an expired recovery) and one case for each of the ten Sheets the board does not draw. The gate runs cold → loading → error → not-found → archived → wall → ready (settings-page-gate.ts); the members read gates the page with the club read because the role is derived from it. The photos behind 프로필 완성도 and 앨범 관리 never block the page: loading is a Cell skeleton, an unread album a dash and no tally.

  • Write failures (ruling EP-3): a setting row says common.saveFailed on its helper; photos, 보관 and 되살리기, and 대표 넘기기 (a Dialog over the Sheet) tell a failure in a Dialog; never a Toast.

  • Offline (F1c, the app's root strip says it): offline the form stays and its band or write rows wait (the kit holds the primary; no page-made disabled flag).

Actions.

  • A Switch → write({ field: !value }); a pick in a Sheet of radio rows → write(patch) and the Sheet closes; an Edit Sheet's 저장 → write(patch), the Sheet closes when it was saved. 구성 leaving 혼성 also writes 종목 back to 복식 when it was 혼복. Turning 불참 제한 off also turns 취소 페널티 off. 방문 횟수 제한 0 is stored as none; 게스트 참가비 유료 needs an amount (0 is 무료).
  • Banner/logo: album pick → useUploadClubMedia (via useClubImagePickers); a default cover → bannerUrl + bannerThumbhash; removals ask first → useRemoveClubMedia.
  • 초대 코드 → the invite Sheet, or after a failed read a retry of the read; 공유하기 → useShareClubInvite, which reads the code when the member shares (no copy button until W5).
  • Owner transfer: candidate → Dialog naming the recipient → useTransferClubOwnership; the Sheet blocks dismissal while pending. Owner archive: Dialog → useArchiveClub → replace with the clubs list. Owner restore: Dialog → useUnarchiveClub → replace with the club.

Gaps. The media hooks keep their own toasts (upload/remove success, upload failure); only useUpdateClub writes are silent. The legacy free-text district entry (club with no court yet) is gone with the board's design: 활동 지역 only follows 테니스장 관리. 대표 권한 넘기기 is drawn even for a sole member (the Sheet then says the owner cannot leave); the candidates have initials, not faces. 사진 찍기 waits for W5. Browser/native acceptance of image picking, the invite share and the archive receipts remains pending; no rendered or hosted-database proof is implied by this date.


Club Deposit Account (입금 계좌) ​

  • Route: /clubs/[clubId]/settings/account
  • Screen: ClubSettingsAccountScreen — packages/features/clubs/src/club-settings-account/club-settings-account-screen.tsx (parts beside it)
  • Shell: DetailShell · title common.depositAccount · sub club.name · footer CtaBand 저장
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: The app's one PageStateView in the page's Header: offline (nothing cached) wins over error, error over loading, then not found (offers 클럽으로, the 클럽 tab), then the wall (the helper's no-permission). A warm copy shows offline. 멤버 관리 does not draw the 가입 신청 queue or the guest roster offline.

Purpose. Where the 총무 sets the club's transfer account (L4-21, flat-v2-forms#ks-bank): club_bank_accounts (00412, one row per club, upserted on clubId), club-admin-only to edit (RLS enforces it too). Reached from 클럽 설정's 입금 계좌 Cell (ClubFeeSection); a leaf. The payer's read view is 클럽 홈 › 회비 (DuesBankCard), not this page.

Anatomy.

├─ [DetailShell] title="입금 계좌" sub=club.name onBack footer=[CtaBand]✦저장 (+ helper when a write failed)
├─ ▸ gate (generic): cold offline → [OfflineEmptyState] · loading → [FieldSkeleton ×3] · error → [QueryErrorState] · club null → [NotFoundState] · !canManageClub → [EmptyState kind=permission]
└─ [FlatColumn top=$2 divider=false]
    ├─ account saved → [RailBleed][CellGroup]: [PickerCell 은행] → [ValueSheet] of the twelve banks · [Cell edit 계좌번호] and [Cell edit 예금주] → [EditSheet commit=done] (one `AccountFieldSheet`)
    └─ none yet → the creation form: [PickerCell 은행 required] then [Field 계좌번호 required][Input tabular] and [Field 예금주 required][Input] (placeholder 예: 김민준)

States. The board's six frames (saved, no account yet, the 계좌번호 and 예금주 Edit Sheets, the bank's value Sheet, the dirty leave) plus the generic ones the lane added to the board (ks-bank-invalid, -saving, -failed, -offline, -loading, -cold, -error, -member): 저장 is soft until an answer differs (a page with no account stays ready and validates on the press: each missing answer states its line, nothing is written); 저장 중... in flight; a failed write is the band's danger helper common.saveFailed (the hook's errorSurface is helper, so no toast); disabled offline (the root strip is the slot). A saved bank outside the twelve (typed before the list existed) shows as the value until a pick overwrites it; no migration. An Edit Sheet refuses a value the form cannot take (empty; 계좌번호 past 40, 예금주 past 20) with its line above 완료 and stays open.

  • Offline (F1c, the app's root strip says it): offline the form stays and its band or write rows wait (the kit holds the primary; no page-made disabled flag).

Actions. 은행 → value Sheet (a pick fills the form); a pencil → Edit Sheet (완료 fills the form, the page's 저장 writes); 저장 → one useUpsertClubBankAccount of the trimmed answers (updatedBy the viewer), success: markSaved, the leave guard released, appRouter.back() and the hook's saved Toast. Back on a changed page asks the edit guard (useLeaveGuard(isDirty, useEditGuardDialog()), the question a changed Edit Sheet asks). The draft is useServerDraft: a refetch to a new object with the same values never clobbers a typed answer (F11).

Gaps. The bank list is the app's DEPOSIT_BANK_OPTIONS (the board's twelve, shared with a 매치's own account), stored as the Korean name; the club's account is persist-denied (F2 cut 2: clubBankAccounts is a financial identifier and never on the device's disk), so a cold start offline shows this page and the deposit Sheet in their cold state until one online read lands; a locale-translated name would store a different value per viewer. The board draws no error lines for the creation form's validation; the page states them in the instruction form. Browser/native acceptance of the numeric keypad and the guard remains pending.


Club Regular Meets (정기 일정) ​

  • Route: /clubs/[clubId]/regular-meets
  • Screen: ClubRegularMeetsScreen — packages/features/clubs/src/club-regular-meets/club-regular-meets-screen.tsx (the page is regular-meets-page.tsx; its reads are use-regular-meets-page.ts, its rows meet-rows.ts)
  • Shell: DetailShell (scroll={false}) · title common.regularSchedule · sub club.name · no footer, no CtaBand · FAB 정기 일정
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: The app's one PageStateView in the page's Header: offline (nothing cached) wins over error, error over loading, then not found (offers 클럽으로, the 클럽 tab), then the wall (the helper's no-permission). A warm copy shows offline. 멤버 관리 does not draw the 가입 신청 queue or the guest roster offline.

Purpose. The club's recurring meets (L3-48, flat-v2-list-a#rm-ideal) as Text rows with the FAB that adds one, for the manager of the club (대표 and 총무, useClubRole(clubId).canManageClub). Reached from 클럽 설정's 활동 Cell 정기 일정 (L4-20). A row opens the meet's editor (L4-22) and the FAB opens it new; there is no row menu, no AddCard and no sentence description.

Anatomy.

├─ [DetailShell] title="정기 일정" sub=club.name onBack scroll=false
├─ ▸ ready or loading → [FeedList] of [TextRow] (l1 = describeRegularMeet, l2 = place · name · "조 편성: A조"; first and last trimmed) · header 12 only while loading · tail 40 clear of the FAB · pull to refresh
│    ├─ loading → [TextRowSkeletons ×4] · no meets → [EmptyState kind=first glyph=calendar] 정기 일정이 없어요 (the FAB is the action)
│    └─ [Fab placed=plain] label 정기 일정, spoken 정기 일정 추가
└─ ▸ any other gate → [ClubHostGateView]: cold offline → [OfflineEmptyState] · error → [QueryErrorState] · club null → [NotFoundState] · !canManageClub → [EmptyState kind=permission] "운영진만 볼 수 있어요" / "정기 일정은 대표와 총무만 관리할 수 있어요"

States. The host's gates (club-manage-host/club-host-gate.ts), in order: cold offline (before everything: the wall would be false to an official who is simply offline), loading, a failed read, a club the viewer cannot see, the wall, else the list. The page stands on four reads: the club, its members, the meets and the 조 that name them (a row never says 조 미지정 for a 조 not loaded yet). Only active meets are listed, in the order the read returns them. A 조 meet says "조 편성: {name}", or 조 미지정 where the 조 was deleted or never picked; 전원 says nothing. Each row is spoken "매주 토요일 09:00부터 12:00, 반포 한강 테니스장, 주말 정모, 조 편성 A조" (an overnight end reads 익일; a meet with no end is its start alone). Board frames: rm-ideal, rm-empty, rm-loading, rm-offline, rm-error, rm-overflow, rm-wall.

Actions. A row → routes.clubRegularMeet(clubId, meetId) · the FAB → routes.clubRegularMeetNew(clubId) · pull to refresh (refetches the meets and the 조). The FAB shows only when the list is ready: not while loading (the role is not known yet), and in no other gate.

Gaps. Browser/native acceptance of the FAB inside the new frame remains pending.



Club Regular Meet Editor (정기 일정 수정) ​

  • Route: /clubs/[clubId]/regular-meets/[meetId]
  • Screen: ClubRegularMeetEditorScreen — packages/features/clubs/src/club-regular-meets/club-regular-meet-editor-screen.tsx (the page is regular-meet-editor-page.tsx; the form is use-meet-form.ts over the pure draft in meet-draft.ts)
  • Shell: DetailShell · title clubList.regularMeet.editMeet · sub club.name · footer CtaBand 저장 (scrolled)
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: A meet the club does not have is not found and offers 정기 일정.

Purpose. One saved recurring meet as a page (L4-22, flat-v2-list-a#rme-edit): one list of value rows (library § 1.22, rule 0), label left and answer right, the pencil opening the row's editor. Reached from a row of 정기 일정 (L3-48); the manager of the club only. The meet is found among the club's active meets (there is no single read), so an unknown or inactive id is not found.

Anatomy.

├─ [DetailShell] title="정기 일정 수정" sub=club.name onBack keyboardShouldPersistTaps="handled" footer=[CtaBand 저장]
├─ [SectionHead 일시] [CellGroup] → [Cell edit] 반복 (매주 토요일) · 시간 (09:00~12:00, the start alone for a meet with no end)
├─ [SectionHead 기본 정보] [CellGroup] → 장소 · 이름 (없음 until set)
├─ [SectionHead 참석자] [CellGroup] → 참석 구성 (전원 | 조 편성) · 조 (only under 조 편성; 선택 until one is picked)
├─ [SectionHead 주의가 필요한 설정] [CellGroup] → [Cell destructive] 정기 일정 삭제
└─ overlays (one open at a time): [EditSheet 반복] ([ChoiceSet] 매주·격주·매월, 몇째 주 for 매월, 요일) · [TimeRangeSheet 시간] (endOptional 종료 없음) · [VenueSearchTakeover] 장소 · [EditSheet 이름] · [EditSheet 참석 구성] · [ValueSheet 조 선택] or [Sheet + EmptyState] 만든 조가 없어요 / 조 만들기 · [Dialog 정기 일정을 삭제할까요?]

States. Not found: an empty route param, a meet the club does not have, an inactive one. Before the form: the host's generic gates (cold offline, loading, error, club null, the wall). Each row's overlay is a board frame: rme-edit, rme-days, rme-week, rme-time, rme-time-open, rme-time-noend, rme-group, rme-group-none, rme-delete, rme-leave. 저장 is soft until something changed or while the end equals the start (a press then shows the reason as the band's helper); a failed save or delete is the band's danger helper, never a toast; the band is disabled offline. The 시간 Sheet's wheel is 10 minutes; an existing time off that step is snapped only when 완료 is pressed.

  • Offline (F1c, the app's root strip says it): offline the form stays and its band or write rows wait (the kit holds the primary; no page-made disabled flag).

Actions. 반복 → Edit Sheet (완료 fills the form; days keep week order; a change to every two weeks or monthly moves the first date to today, a weekly meet keeps its anchor; a 매월 repeat is still repeated every 28 days by the app) · 시간 → the time range Sheet (종료 없음 clears the end: an existing meet with no end round-trips unchanged) · 장소 → the venue search takeover: a pick links the club's own venue (and resets the used courts) only when the club already has it, otherwise the name is kept alone and the old link is cleared; no write to the club's venue list; a Naver pick's second emission counts only for the place the meet still names · 이름 → Edit Sheet · 참석 구성 → Edit Sheet · 조 → value Sheet, or with none made 조 만들기 → routes.clubGroups(clubId) (a push, so the draft survives) · 정기 일정 삭제 → Dialog, then one useDeleteRegularMeet, the guard released, back · 저장 → one useUpdateRegularMeet (the first date only when the repeat moved it), success: markSaved, the guard released, appRouter.back() and the hook's saved Toast. Back on a changed page asks the edit guard (useLeaveGuard(isDirty, useEditGuardDialog())).

Gaps. A deleted meet leaves its sessions (the link is set to null). Browser/native acceptance of the Sheets, the takeover and the guard remains pending.



Club Regular Meet New (정기 일정 만들기) ​

  • Route: /clubs/[clubId]/regular-meets/new
  • Screen: ClubRegularMeetEditorScreen — packages/features/clubs/src/club-regular-meets/club-regular-meet-editor-screen.tsx (meetId null; the same page as the editor)
  • Shell: DetailShell · title clubRegularMeetEdit.titleNew · sub club.name · footer CtaBand 만들기 (scrolled)
  • Design: unreviewed
  • Verified: 2026-11-13

Purpose. A new recurring meet (L4-22, flat-v2-list-a#rme-new): the editor with meetId null, reached from 정기 일정's FAB (L3-48), begun from the app's defaults (매주 토요일 19:00~21:00, 전원), so every row has a value and the place and the name read 없음. The manager of the club only.

Anatomy.

├─ [DetailShell] title="정기 일정 만들기" sub=club.name onBack keyboardShouldPersistTaps="handled" footer=[CtaBand 만들기]
├─ [SectionHead 일시] [CellGroup] 반복 · 시간
├─ [SectionHead 기본 정보] [CellGroup] 장소 · 이름
├─ [SectionHead 참석자] [CellGroup] 참석 구성 (· 조 under 조 편성)
└─ the same overlays as the editor, without the delete (no caution group)

States. The editor's gates and Sheets. 만들기 is ready from the start (soft only while the end equals the start); a failed create is the band's danger helper. Board frame: rme-new.

  • Offline (F1c, the app's root strip says it): offline the form stays and its band or write rows wait (the kit holds the primary; no page-made disabled flag).

Actions. The editor's, and 만들기 → one useCreateRegularMeet (the first date is the day the meet is made on, or the day a changed repeat moved it to), success: the guard released, back to 정기 일정 and the hook's saved Toast. Back with changes asks the edit guard.

Gaps. As the editor. A new meet gets no sort order (the column default), as before.


Club Journey (클럽 테니스 여정, L3-51) ​

  • Route: /clubs/[clubId]/journey
  • Screen: ClubJourneyScreen — packages/features/clubs/src/club-journey/club-journey-screen.tsx (route params only; the page is club-journey-page.tsx)
  • Shell: DetailShell · title clubJourneyPage.title (클럽 테니스 여정) · sub club.name · scroll={false} (the list scrolls) · no FAB, no CtaBand
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: The app's one PageStateView in the page's Header: offline (nothing cached) wins over error, error over loading, then not found (offers 클럽으로, the 클럽 tab), then the wall (the helper's no-permission). A warm copy shows offline. 멤버 관리 does not draw the 가입 신청 queue or the guest roster offline.
  • Notes: 2026-11-11 (W4 batch 23): built to flat-v2-list-b#club-journey. Told from the club's own data, never a member's note. Gated by the entitlement clubJourneyEnabled (false on free and player; true on basic, pro, club_plus).

Purpose. The club's current month as sessions, results and photos by day, newest first, for the members of a club whose plan offers it. Reached from 클럽 홈's 기록 pane, whose closing Cell group holds 클럽 테니스 여정 after 선수별 전적 (door #312).

Anatomy. One gate (journeyGate), then one hook for the month's reads (useJourneyPage).

├─ [DetailShell] title=클럽 테니스 여정 sub=club scroll=false
├─ ▸ stale (offline with what was read): the list under the root strip
└─ gate (journey-gate.ts): cold → OfflineEmptyState · error → QueryErrorState what=클럽 기록 · loading → JourneyLoading
   · not-found → NotFoundState · wall → EmptyState permission · plan → nothing + [Sheet] · first → EmptyState first (calendar) · ready → JourneyList
      ├─ ▸ loading: 12 + [SectionHead first 이번 달] + [Strip tiles loading ×3] + 24 + [TextRow loading ×5]
      ├─ ▸ plan: the bare Header and [Sheet] 클럽 테니스 여정은 클럽 플랜에서 볼 수 있어요 (body only, no footer); × → back, or replace to 클럽 홈
      ├─ ▸ first: [EmptyState first, calendar] 아직 쌓인 기록이 없어요 / 일정과 경기, 사진이 생기면 여기에 모여요 (no head, no Strip)
      └─ ▸ ready: [FeedList perfLabel="club-journey"]
         ├─ header: 12 + [SectionHead first 이번 달] + [Strip tiles] 일정 · 경기 · 사진 (the month's sessions, matches in the kept results, photos)
         └─ per day, newest first: [DayHeader] then [TextRow] × N: a session (calendar) → its result (trophy) → that day's uploads, one per uploader (image)
            · session: l1 sessionTitle(session) · l2 [venue, start time]
            · result: l1 "{sessionTitle} 결과" · l2 ["N경기", "최다승 {name} N승" (none for a friendly session)]
            · photos: l1 "{M월 D일} 사진" · l2 ["N장", uploader]
            · spoken name: the kind word first (일정, 경기 결과, 사진), then l1 and the facts; the glyph is silent

States. Order: cold, error, loading (the club bundle and the viewer's role, the club's subscription, and the month's three reads once they may run), not found, wall (not a member of the club: the permission wall), plan (the club's plan does not offer it: the Sheet over the bare Header), first (nothing this month), then the page. The month's reads run only for a member of a club whose subscription has settled with the entitlement on; until then no sessions, results or photo read is made. Stale: offline with what was read keeps the list and its rows, under the strip. A result whose session is not among the month's sessions (a draft or cancelled one, or one past the sessions read) is left out and not counted: its only other title would be the stored free text, which is never shown. A name not resolved yet (a winner, an uploader) is left out of the facts rather than guessed.

  • Offline (F1c, the app's root strip says it): offline the cached entries stay under the root strip with no strip of its own, and the rows still open (jc-stale).

Actions. A session row and a result row → routes.clubSession(clubId, sessionId); a photo row → routes.clubAlbum(clubId). Pull to refresh (club, subscription and the month's reads). Back. The plan Sheet's × → back, or replace to routes.club(clubId) when there is no history. No button for anyone.

Gaps. The plan gate is client-side only, as 성장 분석's is: the sessions, results and photo reads are limited by membership and RLS, and nothing server-side checks the club's plan. The plan Sheet has no button for anyone until a club plan page exists (the board's jc-plan footer 클럽 플랜 보기 is not built; jc-plan-member is the one built frame, for officials too). Only the current month (device-local, as getMonthRange): there is no month picker. The photos row's title is "{date} 사진" (the board's "9월 정모 사진" has no source), and a result row's title is the session's own title with 결과 (the board's named titles are generated, not stored). Nothing ran on a device.


Club Groups (조) ​

  • Route: /clubs/[clubId]/groups
  • Screen: ClubGroupsScreen — packages/features/clubs/src/club-groups/club-groups-screen.tsx (route params only; the page is club-groups-page.tsx)
  • Shell: DetailShell · title clubManagePages.groupsTitle (조) · sub club.name · scroll={false} · no footer, no CtaBand · FAB 조 (placed="plain", spoken 조 추가)
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), Actions: Dismissing (the ×, the scrim, back) with a typed note, a changed 조 or a changed amount asks the edit guard (계속 수정, 그만두기); 그만두기 discards; with nothing typed it closes; while the write runs every way out is held.
  • Batch 58 (ST1, 2026-11-13), States: The app's one PageStateView in the page's Header: offline (nothing cached) wins over error, error over loading, then not found (offers 클럽으로, the 클럽 tab), then the wall (the helper's no-permission). A warm copy shows offline. 멤버 관리 does not draw the 가입 신청 queue or the guest roster offline.

Purpose. The club's skill groups for its officials, built to flat-v2-list-a#gr (W4 batch 22): a list edited in Sheets (page-links.json leaves L3-49). A 조 is a Text row that opens its Sheet; 재편성 ends the list and previews who moves; the FAB adds a 조. Reached from 클럽 설정's 활동 Cell 조 (L4-20) and 정기 일정 수정's 조 만들기 (L4-22). The rank partition (computeRankPartition), the 재편성 flow, the club's rating (the member's one global doubles rating) and the manual pin stay the backbone rule's (club-groups.rules.ts); this page presents them.

Anatomy.

├─ [DetailShell] title="조" sub=club.name onBack scroll=false
├─ ▸ list (gate ready or loading): [FeedList perfLabel="club-groups"] + [Fab 조]
│  ├─ rows: [TextRow] l1 name · l2 ["현원 N명", "목표 N명" (only with a target)] · first trimmed 12 · onPress → the 조 Sheet · no ⋯
│  ├─ loading: [TextRowSkeletons ×3] (no FAB: the role is not known yet)
│  ├─ empty: [EmptyState first glyph=users] "아직 조가 없어요" / "조를 추가하고 재편성을 실행해 보세요" (FAB the only action, no 재편성)
│  └─ footer ▸ rows exist: 32 · [RailPad > Button tone=tonal size=md full] 재편성 · 40
├─ ▸ otherwise a state: [OfflineEmptyState] (cold) · [QueryErrorState what="조"] · [NotFoundState] · [EmptyState permission] "운영진만 볼 수 있어요" / "조는 대표와 총무만 관리할 수 있어요"
└─ one surface at a time over the list (GroupsView):
   ├─ [GroupSheet] (bleed) "조 추가" | "조 수정": ▸ limited: [Cell choice] "목표 인원 (명)" + typed [Stepper] 2..40 · [CheckRow check] 제한 없음 · [RailPad > Field 조 이름 (limit 20)] · ▸ edit: [Cell destructive] 조 삭제 · footer 추가 | 저장 (soft until a name)
   ├─ [Dialog] "{name}을 삭제할까요?" / "소속 멤버는 미배정이 돼요" — 닫기 | 삭제 (danger, committing while it writes)
   └─ [RegroupSheet] (bleed) "재편성 미리보기": [SheetGroupTitle] 승격 · 강등 · 신규 배정 · 미배정 over [PersonRow l2 "B조 → A조" · "ELO 1,523" · "12전" (l2Lines 1, spoken "이서연, B조에서 A조로, ELO 1,523, 12전")]; [SheetGroupTitle] 고정됨 over dimmed [PersonRow + Tag 고정됨]; footer 확정

States. Cold offline, error, loading, not found, wall (a member) and ready, in that precedence (groupsGate); the page stands on the club bundle, the 조 and the members. A 조's roster is the active members whose effective 조 (the manual pin, else the auto assignment) is that 조, the basis 재편성 uses. The preview's own reads (ratings, profiles) settle before it offers anything: while they load 확정 is off and the body is skeleton rows; offline or a failed read shows the shared state with its retry; with nothing to move the Sheet says 변경될 배정이 없어요 and 확정 is off. An unrated member's move carries the matches played and no ELO.

  • Offline (F1c, the app's root strip says it): offline with cached rows the page stays under the root strip; the FAB and write rows wait; cold offline is the offline state.

Actions. A row → 조 수정; the FAB → 조 추가 (a new 조 starts unbounded; it fills after the last one); 저장/추가 writes (useUpdateClubGroup / useCreateClubGroup) and closes once it landed (a refusal is the Dialog, the Sheet stays); 조 삭제 → the Dialog → useDeleteClubGroup (the members become unassigned by the foreign keys); 재편성 → the preview → 확정 sends the rule's whole assignments list in one useApplyGroupAssignments and closes; × closes any surface without writing; back → appRouter.back().

Gaps. 재편성's preview is computed on the client from the cached roster and ratings and the server applies the list it is sent: if members or ratings change between the preview and 확정 the stale list is applied (unchanged by this page; atomic and validated server-side). A refused add, edit, delete or 확정 is one Dialog (title 조를 바꾸지 못했어요, body common.saveFailed, 닫기) over the surface that stays open, never a Toast (the four mutation hooks name errorSurface: 'dialog'); the board draws no failure frame. The board draws no loading frame for the preview Sheet and no cold or error frame for it: those states are this page's. The board's loading frame now draws no FAB either: the add button waits for the role. The tier dot (GroupTierDot, the median rating's tier colour) and the eligibility sentence (경기 미만인 멤버는 자동 배정에서 제외돼요) are dropped, per the board (ruling G5). Browser/native acceptance of the Sheets and the Dialog inside the frame remains pending.


Club Album Manage (앨범 관리) ​

  • Route: /clubs/[clubId]/album/manage
  • Screen: ClubAlbumManageScreen — packages/features/clubs/src/club-album-manage/club-album-manage-screen.tsx (the route's screen over ClubAlbumManagePage, club-album-manage-page.tsx; the reads, gate and overlay state are use-album-page.ts, the list album-list.tsx over the pure album-items.ts / album-groups.ts / photo-rows.ts, a row photo-item.tsx, the photo Sheet photo-sheet.tsx over the pure photo-draft.ts / reorder-model.ts, the Dialogs album-overlays.tsx, the writes use-album-writes.ts and album-failure.ts, the uploads use-album-upload.ts over the gallery function of settings/use-club-image-pickers.ts)
  • Shell: DetailShell · title clubManagePages.albumManageTitle (앨범 관리) · sub club.name · onBack · scroll={false} (the list scrolls) · no footer, no CtaBand · FAB 사진 (placed plain)
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: The app's one PageStateView in the page's Header: offline (nothing cached) wins over error, error over loading, then not found (offers 클럽으로, the 클럽 tab), then the wall (the helper's no-permission). A warm copy shows offline. 멤버 관리 does not draw the 가입 신청 queue or the guest roster offline.

Purpose. The club's photos for its officials (L3-50, flat-v2-list-a#am): what waits for an answer first and answered on its row, the public photos with the 대표 marked, then the ones turned down; a row opens its Sheet and the FAB adds photos. Owners and admins only: since migration 00735 a member no longer reads a photo that waits or was refused (only approved photos and their own uploads), so anyone else meets the wall. /clubs/[clubId]/album (ClubAlbumScreen) is the viewers' album and a different page.

Anatomy.

├─ [DetailShell] title="앨범 관리" sub=club.name onBack scroll=false
├─ ▸ gate (host's reads + isAdmin): cold offline · loading ([SkeletonKind media ×6]) · error (what 사진) · not found · wall "운영진만 볼 수 있어요" / "사진은 대표와 총무만 관리할 수 있어요"
└─ ▸ ready: [FeedList] (pull to refresh, tail clears the FAB)
    ├─ ▸ plan without a gallery: [Notice] "앨범은 상위 플랜에서 이용할 수 있어요" (16 above and below) · ▸ gallery full: [Notice meaning=attention] "사진 한도에 도달했어요" + the two ways out
    ├─ ▸ 승인 대기 (N장): [SectionHead first] · ▸ photos on their way: [MediaRow loading] each · [MediaRow venue 80] (thumb, l1 caption else alt text else 사진, l2 [kind, audience]) + [AnswerPair 승인 · 거절] rail to rail
    ├─ ▸ 공개 중 (N장): [SectionHead action=앨범 보기 ›] · [MediaRow] (Tag 대표 trailing for a featured photo, whose l2 leaves the kind out)
    ├─ ▸ 거절됨 (N장): [SectionHead] · [MediaRow] ending in the sm Button 승인
    ├─ ▸ no photo at all: [EmptyState first glyph=image] "아직 올린 사진이 없어요" (the FAB is the action)
    └─ [Fab 사진] (aria 사진 추가; busy while photos upload; absent for a plan without a gallery and a full gallery)
Overlays: [Sheet 사진 설정] (bleed; footer 저장, soft until changed): [CheckRow 대표 사진] (absent when the plan's 대표 allowance is 0; locked with 대표 3/3 when taken) · [Cell choice 순서 (번째) + Stepper] (a 공개 중 photo only) · [ChoiceSet 종류 갤러리|일정 앨범] · [ChoiceSet 공개 범위] · [Field 사진 설명 160] · [Field 대체 텍스트 240] · [Cell destructive 보관]
          [Dialog 사진을 거절할까요?] · [Dialog 사진을 보관할까요?] · [Dialog] a refused write · [Dialog] a failed upload batch

States. The host's six gates (cold offline first: a paused read is neither loading nor failed), with the wall also for a role that holds the manage permission but is not an owner or admin (the server's rule is owner or admin). Ready: ideal, empty, the plan notice, the limit notice, uploading (the picked photos draw as loading rows in 승인 대기 and count with it; the FAB is busy until the batch ends). The list reads COMPLETE (pages of 60 inside the adapter), so the limit and the counts see every photo.

Actions. 승인 (row pair, or 승인 on a turned-down photo) publishes at once, signed with the approver and the moment. 거절 asks a Dialog, then sets rejected and clears the signature. A row's tap opens the Sheet: 저장 sends only the changed fields (useUpdateClubMedia) and, when 순서 moved, one useReorderClubMedia call with the 공개 중 ids in their new order (the server renumbers atomically); un-checking 대표 sends the photo back to 갤러리; 보관 closes the Sheet and asks a Dialog, then useArchiveClubMedia. The Door pill 앨범 보기 › opens 앨범 (L3-23, door 311). FAB 사진 → the multi-photo picker (up to CLUB_MEDIA_PICK_LIMIT; at the plan limit the limit Dialog and no picker); each asset uploads in turn, silently, and the batch ends in ONE summary Toast; photos the upload refused make a Dialog naming how many (a failed write is never a Toast). A refused write is a Dialog: a list that moved (reorder_club_media 22023 invalid_media_order / media_not_in_club) says to refresh and is read again on 닫기, 42501 repeats the wall's line, anything else the generic save failure; a defect that is not a DomainError propagates.

Gaps. The Sheet's 저장 is two writes when both the fields and 순서 changed (each atomic; the second runs only if the first went through). Browser/native acceptance of image picking remains pending.


Club Event Create (행사 만들기) ​

  • Route: /clubs/[clubId]/events/new
  • Screen: EventCreateScreen — packages/features/clubs/src/event-create/event-create-screen.tsx (the access gate is use-event-access.ts, the answers and the reveal latch use-event-form.ts over the pure event-rules.ts, the write and the leave guard use-event-submit.ts, the band use-event-band.ts over the pure event-band.ts; the pieces are event-sections, event-place-row, event-date-sheet, create-gate-view, and the place search, PlaceSearchTakeover of @twomore/app (presentation/components/place-search/: place-search-takeover / place-search-body / use-place-search-state / place-search-view, moved there 2026-11-10 for 장소 정보 제안))
  • Shell: DetailShell · title t().clubEvent.create.title · sub club.name · onBack · footer CtaBand 행사 만들기 (none while the page is a state)
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: the host gate is the clubs page state helper; the order is offline, error, loading, not found (클럽으로), then the wall, and the page asks before discarding typed input as before.
  • Notes: 2026-11-08 (W4-3 lane P1): built to the board flat-v2-forms#ev-ideal (17 frames: 11 page states, 2 overlays, 4 place-search). Any active member may make an event (the club_events insert policy, E1); the 일시 Sheet is the kit's DateTimeSheet, the map face the kit's MapFace in 장소's expand, the count the Stepper nullable.

Purpose. Make a club 행사 (L4-03; club_events, a dinner or a social, owner decision D2): a title, a day and start, a place and, optionally, an introduction and a cap on who may come. Reached from the 클럽 홈 만들기 FAB once the hub wave draws it (until then by link); success replaces to the event (/clubs/[clubId]/events/[eventId]). Members only.

Anatomy.

├─ [DetailShell] title="행사 만들기" sub=<club name> onBack footer=▸[CtaBand]✦행사 만들기
├─ ▸ gate (cold → loading → error → wall → ready): cold offline [OfflineEmptyState] · loading [FieldSkeleton n=5 text,text,picker,picker,picker] · error [QueryErrorState] · wall [EmptyState permission] "클럽 멤버만 만들 수 있어요" (no action; the header back is the way out)
└─ ▸ ready [EventSections] (sequential reveal, a latch)
    ├─ [SectionHead first 행사] [RailPad gap=$8] → [Field 제목 required] [Input limit=80] · [Field 소개 optional] [TextArea limit=500]
    ├─ ▸ title ≥ 2 characters: [SectionHead 일시와 장소] [CellGroup] → [PickerCell 일시 required] → the 일시 Sheet
    │                                                      └─ ▸ 일시 set: [PickerCell 장소 required expand=▸[MapFace]] → the place search takeover
    └─ ▸ 일시 set: [SectionHead 인원] [CellGroup] → [Cell choice 최대 인원 (명)] trailing [Stepper nullable typed 2–100] (unset: — and 제한 없음)
[DateTimeSheet 일시] — Calendar, then 시작 시간 on a 06:00–24:00 track; past start refused (지난 시각은 고를 수 없어요)
[Modal ▸ SearchTakeover] header search 장소 이름이나 주소를 검색해 보세요 · body ▸ 최근 검색 [TextRow]× · 네이버 검색 결과 N곳 [MediaRow venue, map art tile] · [EmptyState compact filtered] · footer ▸ [CtaBand] 이 장소로

States. The gate above; the reveal only grows (a group that appeared stays when the answer that called for it is cleared). Band: soft while an answer is missing, the ball primary when the form is complete, 만드는 중... while it writes, a danger line (행사를 만들지 못했어요. 다시 시도해 주세요) above an active primary after a failed write with the input kept, held (disabled, not soft) offline; none on the loading and wall states. A title or introduction past its limit keeps the text and flags it, the band stays soft. The place search is submit-driven (the keyboard's 검색, at least two characters; a keystroke never asks); the previous rows stay while the next search loads; a failed provider is an error with a retry, never an empty list; emptying the bar returns to the recents.

  • Offline (F1c, the app's root strip says it): offline the form stays and its band or write rows wait (the kit holds the primary; no page-made disabled flag).

Actions. Type 제목 and 소개; 일시 → the Sheet (완료 writes the day and start into the form; a past start keeps the Sheet open with its line); 장소 → the takeover (a row stages with its check, a second tap takes it back, 이 장소로 alone commits and closes; the pick shows as the map face under the row, whose tap reopens the search; a recent searches again, its × forgets it); 최대 인원 Stepper (+ from the dash sets 2, − at 2 returns to 제한 없음). A press on the soft band names the first missing answer and shows its line (제목을 입력해 주세요 with the caret in the Field, 일시를 골라 주세요, 장소를 골라 주세요). 행사 만들기 → useCreateClubEvent (location the place's name, locationUrl its Naver link, no cap for 제한 없음) then the mutation's Toast 행사를 만들었어요 and appRouter.replace(routes.clubEvent(...)) after the guard is released. Back on a typed form asks 나갈까요? (useLeaveGuard with the leave Dialog; not while the write is in flight).

Gaps. The place's address and coordinates are client state for the map face only (club_events keeps the name and a link), so the face exists during the pick and 행사 수정 draws the plain row. The soft band's press shows the 일시 and 장소 lines but opens nothing (the kit's rows take no focus). The CtaBand keyboard prop is not wired (no keyboard-visibility hook exists); the takeover relies on the search's submit dismissing the keyboard. The takeover's Modal host, its safe area and focus-on-show, and the Android back, are unverified on a device. Browser/native acceptance remains pending.


Club Event (행사) ​

  • Route: /clubs/[clubId]/events/[eventId]
  • Screen: ClubEventScreen — packages/features/clubs/src/club-event/club-event-screen.tsx (the reads, the gate and the answers are use-club-event-page.ts; the pure view model is event-view-model.ts; the pieces are event-hero, event-info, event-attendees, event-band, event-menu, use-cancel-event; 수정 is event-edit-page.tsx with its Sheets event-edit-sheets, event-cap-sheet, event-when-sheet)
  • Shell: DetailShell · no title (the Hero's h1 is the page's name) · onBack · header ⋯ for the creator or an owner/admin of an upcoming open event · footer CtaBand (참석하기 · 참석 취소 · 정원 마감 · none). Edit mode: DetailShell · title clubEvent.detail.editTitle (행사 수정) · sub club.name
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: A missing event is not found and offers 클럽으로 (the club's own page); a non-member meets the wall before not found.

Purpose. An event of a club (L2-07; club_events, a dinner or a social, owner decision D2): when, where, how many of the cap, the club, who is coming, and the one action. Members only (RLS, migration 00695). Reached from 행사 만들기 (whose success replaces to it), from the club_event_created and club_event_cancelled pushes, and, once the hub wave draws the Feed row, from 클럽 홈. The board is flat-v2-detail#ev (21 frames with ev-nocap).

Anatomy.

├─ [DetailShell] title="" onBack actions={creator | admin, open, upcoming → [⋯ MenuSheet 수정 · 행사 취소 (destructive)]} footer=▸[CtaBand]
├─ ▸ gate (cold → loading → error → wall → not found → ready): cold offline · loading ([Hero loading] [SectionHead 참석] [SkeletonKind person ×4]) · error · wall "클럽 멤버만 볼 수 있어요" (no action) · not found "행사를 찾을 수 없어요" (홈으로 가기 · 돌아가기: event null, or an event of another club)
├─ ▸ cancelled / over: [Notice] in its title alone (취소된 행사예요 · 끝난 행사예요), 24 above
├─ [Hero] eyebrow 행사 · title · facts: [calendar] when · [map-pin] place (absent without one) · [users] "6/20명" ("6명" with no cap) + [RsvpStatusBadge confirmed] (참석 확정, the status-badge canon in place of the board's plain 참석 Tag) once the viewer is in
├─ [SectionHead 정보] [CellGroup] → [Cell nav 클럽] → club home · description as reading text on the rail, 12 under (absent without one)
├─ [SectionHead 참석] [PersonRow context=participants] per attending member, oldest first · none → [EmptyState first compact] 아직 참석자가 없어요
└─ 40 · footer band

Edit mode (a state of the same route; back leaves it first): [Cell edit 제목] [PickerCell 일시] [PickerCell 장소] [Cell edit 최대 인원] [Cell edit 소개], each opening its own Sheet: TextEditSheet (제목, 소개, 장소), EventCapSheet (CountCell 2–100 in an EditSheet) and the shared DateTimeSheet.

States. The gate above. Phase is derived: cancelled and completed from the status, past from the start (date and time, an event with no time runs to the end of its day); the status stays open until something completes it. Full is a capped event with every seat held, for a viewer who is not in. Nobody in is the section's own empty state.

  • Write failures (ruling EP-3): 참석 and 불참 say their refusal on the band's helper; 행사 취소 is told in a Dialog; never a Toast.

Actions. 참석하기 → useRsvpClubEvent (attending) and the Toast 참석을 신청했어요 with 되돌리기 (takes the answer back); a refused last seat (HINT club_event_full) is the hook's Toast 자리가 방금 다 찼어요 and the page reloads who is in → 정원 마감. 참석 취소 → useCancelClubEventRsvp at once (no fee is held, so no Dialog). ⋯ 수정 → edit mode; ⋯ 행사 취소 → a Dialog naming the event and "참석자 N명에게 알려요" (nobody in: 이 행사를 취소할까요? alone), whose 행사 취소 writes cancelled (migration 00710 notifies the attendees) and the page turns into the cancelled state. Every edit Sheet writes one field at once and silently (S5): a failure keeps the Sheet open with its line; 일시 refuses a past start (지난 시각은 고를 수 없어요) and 최대 인원 a cap under the attending count (참석자보다 적게 정할 수 없어요; the database refuses it too). The 클럽 row → routes.club; an attendee → that person's profile.

Gaps. The attendee level Tag is not drawn: a member's level here would be their tier's NTRP band from useClubRatingRanks, not a level of their own (E8). 장소 is a typed name (it clears the old locationUrl) until 행사 만들기's place search takeover exists; 최대 인원 cannot be returned to 제한 없음 (the board draws a count with a minimum of 2). The hardware back button leaves the page from edit mode (only the header back leaves edit first). Browser/native acceptance remains pending.


Club Venues (테니스장 관리, L3-29) ​

  • Route: /clubs/[clubId]/venues
  • Screen: ClubVenuesScreen — packages/features/clubs/src/club-venues-screen.tsx (route only; the page and its parts under club-venues/*)
  • Shell: DetailShell · title common.manageVenues (테니스장 관리) · sub club.name · headerRight: none (the 제안 내역 action is gone: it opens from 설정's 보낸 제안 and the owner's Notice) · scroll=false
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: The app's one PageStateView in the page's Header: offline (nothing cached) wins over error, error over loading, then not found (offers 클럽으로, the 클럽 tab), then the wall (the helper's no-permission). A warm copy shows offline. 멤버 관리 does not draw the 가입 신청 queue or the guest roster offline.
  • Notes: 2026-11-10 (W4 batch 21b, board flat-v2-list-a#vn): rows are VenueFeedItem (the venue Feed item, with the club's own courts leading the who row); CtaBand became the FAB. A linked row reads the spine's facts in one bulk useDirectoryVenuesByIds; a legacy row (no spine venue, or the spine read not yet arrived) is a stand-in that states only the club's own row (no rating, hours or price: nothing is guessed). The wall now follows canManageClub from useClubContext.

Purpose. The club's venues for its officials: the list (each row a door to 장소 상세, whose ⋯ holds the club's commands for the venue) and the one way to add one, the shared finder. The venue-provenance canon holds: no row comes from a typed name.

Anatomy.

├─ [DetailShell] title=테니스장 관리 sub=club.name onBack scroll=false
├─ ▸ cold offline → [OfflineEmptyState] · failed → [QueryErrorState] · club missing → [NotFoundState]
├─ ▸ not an official (canManageClub false) → [EmptyState] kind=permission — 운영진만 볼 수 있어요 (no FAB)
└─ ▸ official (loading: no FAB)
   ├─ [FeedList] header 20 (16 while loading) · [VenueFeedItem]×N (clubCourts: 우리 클럽 3~5번 코트) · footer 40
   │  ├─ ▸ loading → [FeedItemSkeleton who n=5] · empty → [EmptyState] kind=first glyph=pin
   ├─ [Fab] 테니스장 (spoken 테니스장 추가) placed=plain
   └─ [VenueSearchTakeover] commitMode=immediate offerContribution pickedVenueIds=the linked rows' spine ids

States.

  • Write failures (ruling EP-3): a venue that cannot be added is told in a Dialog (the generic line, or the no-permission line); never a Toast.

  • Offline (F1c, the app's root strip says it): offline with cached rows the page stays under the root strip; the FAB and write rows wait; cold offline is the offline state.

  • Loading: skeleton Feed items, no FAB. Cold offline: offline alone (no FAB), never "no venues". Empty: 추가한 테니스장이 없어요 with no button (the FAB is the action). A long place crops on one line (the Feed item's rule).

  • A pick of a venue already on the list adds nothing and leaves the finder open on its check; an id-less Naver pick's first emission creates nothing, the adopt flow's resolved second emission creates exactly one row.

Actions.

  • Row → routes.venue(venue.id) (the spine venue, else the club's own row, which the page resolves); FAB → the finder; a pick → useCreateVenue with venueInputFromPick; a search that found nothing (the contribution door) → routes.newVenueContribution(); pull to refresh.

Gaps.

  • The finder's own look for a picked row (이미 추가했어요) and the footer door 목록에 없는 테니스장 추가 are VenueSearchTakeover's (pickedVenueIds, offerContribution), not drawn by this page.
  • Browser/native acceptance remains pending.

Create Club Wizard (클럽 만들기) ​

  • Route: /create-club
  • Screen: CreateClubScreen — packages/features/clubs/src/create-club-screen.tsx (steps: create-club-step{1..8}.tsx + create-club-step-guests.tsx; the step switch is create-club/screen/step-body.tsx, the footer screen/footer.ts, the draft's resume screen/use-draft-lifecycle.ts, the region seed screen/use-region-seed.ts; step parts under create-club/step1/*, step2/*, step3/*)
  • Shell: WizardShell for the editable draft · testID create-club.wizard · headerRight: n/a (WizardShell owns close/back); DetailShell for the issued-creation/optional-setup receipt
  • Design: unreviewed
  • Verified: 2026-11-15
  • Draft: opening the wizard saves no draft: the profile's region seeds 활동 지역 through the silent setter, and the draft is written at the first real edit.

Purpose. The 9-step wizard for creating a new club — name/branding, region/courts, schedule, style, headcount targets, dues, description, membership/guest policy, then review and submit. Once creation is issued, a durable receipt separates confirming that club's creation from retrying optional court, schedule, or logo setup.

Anatomy.

├─ ▸ issued creationIntent or committed setupProgress → [ClubSetupReceipt] in [DetailShell] (the safe-area rule keeps the shell)
│   ├─ [EmptyState kind=error]: 클럽은 만들었어요 / 클럽을 만들었는지 확인하지 못했어요 with its body and one primary (retry, or 다시 확인)
│   └─ the incomplete stages as static Cells under it · secondary 클럽 설정에서 마무리 (committed club) · ending asks 여기서 끝낼까요? (계속 확인 · 끝내기)
└─ ▸ ordinary editable draft → [WizardShell] stepIndex=nav.step totalSteps=9 onClose=nav.handleDismiss onBack=nav.armedOnBack
    │   title=stepTitle(nav.step) (the step's question, drawn by the shell) · primary = `footerOf(...)`: SOFT while the step's required answers are missing (a press sets `attempted` and the step shows the first missing field's line), loading 만드는 중…, the danger helper 클럽을 만들지 못했어요. 다시 시도해 주세요 after a failed create, disabled offline · secondaryLabel (건너뛰기) on 회비/소개 only, hidden while armed or a field buffer is open
    └─ [ScrollView ref=scrollRef] → [StepBody step=nav.step] (the steps draw no hero of their own):
        ├─ 0 [Step1] 이름·로고 — [Field 클럽 이름 *] (placeholder 예: 강남 라켓클럽; three lines: 입력해 주세요 on the press, 2자 이상 after leaving, 30자 이하 as typed) + the same-name [Notice] (informational, never gates) → at two characters the group 로고 ([SectionHead]): 미리보기 ([MediaRow] wearing the generated mark or the photo) · 대표 색상 * ([CountCell] with the compact Stepper 1–12 and the [HueBand] in its expand) · [Field 로고 방식 *] ([ChoiceSet fill] 로고 업로드 | 문양 사용, nested 12 under it the photo [MediaRow] with its [MenuSheet] — 앨범에서 고르기, 문양 쓰기 — or the generator: 모노그램, 글자 크기, 모양, 문양 선택 as one sideways row of eight) → 빠르게 만들기 ([SectionHead] + [CheckRow kind="check" sub], U-32: seeds 구성/종목/수준 범위/성격/게스트-허용, shortens the sequence below)
        ├─ 1 [Step2] 지역 — [PickerCell 활동 지역 *] + [ValueSheet] of the 17 시/도 (seeded from the profile; with none it is the only section) → [PickerCell 자주 가는 테니스장 (선택)] opening the Search takeover (immediate mode) → 선택한 테니스장 ([MediaRow kind=venue] with the home ring and the trash; a tap opens the court panel UNDER ITS ROW: 사용하는 코트 multi set, or 사용 코트 수 (면) [CountCell] + edit [Cell] per court name in the shared text Sheet; 다른 테니스장으로 변경, 정보 수정)
        ├─ 2 [Step3] 일정 — [ChoiceSet 정기 일정 *] 있어요 | 아직 없어요 → committed rounds as `Cell variant="edit"` (매주 토 09:00~11:00, line 2 the venue/courts; a tap re-opens the round) → the active round's form: [PickerCell 활동 장소 *] (the home court, or the 활동 지역 when no court is picked) + [ChoiceSet 요일 *] → [PickerCell 반복 *] + [ValueSheet] of seven → [Field 시간] ([BandSlider] through `time-band-model.ts`) + [ChoiceSet 사용 코트 (선택)] → tonal [Button 다음 일정 추가]
        ├─ 3 [Step4] 스타일 — [ChoiceSet 구성 *] → [ChoiceSet 종목 *] → [ChoiceSet 수준 범위 *] 수준 무관 | 범위 설정 with [LevelBand] nested under 범위 설정 (one stop per tier; `shared/tier-range.ts`), the Field's Info hint opening 티어 안내 (no credential rows: ruling C6) → [ChoiceSet 성격 *], sequential reveal; `attempted` shows the pending set's line
        ├─ 4 [Step5] 인원 — [CountCell 1인당 경기 수 (경기) *] (— while unset) → [CountCell 정원 (명) *] (2–200) → [Cell disclosure 왜 이 숫자죠?] over static Cells 경기 슬롯 · 플레이 시간 · 1인당 경기 · 일정마다 필요한 인원 · 예상 출석률 (no schedule: 정원 alone)
        ├─ 5 [Step6] 회비 (skippable) — per court [Field 시간당 코트비 (원)] → [CountCell 운영비 (%)] → [Cell 제안 월 회비] with one sm `적용` → [ChoiceSet 회비 주기] → [Field 월 회비 (원)] → [CountCell 납부일 (일)] (the per-venue arithmetic is gone: ruling C4)
        ├─ 6 [Step7] 소개 (skippable) — [Field 소개 TextArea 0/500] · [ChoiceSet 클럽 찾기에 공개] 공개 | 비공개 · [PickerCell 사진 정책] + [ValueSheet]
        ├─ 7 [StepGuests] 멤버십·게스트 (required) — 가입 방식 as radio [CheckRow]s with their meanings → [ChoiceSet 게스트 허용 *] → under 허용: [ChoiceSet 게스트 참가비] 무료 | 유료 (the draft's `guestFeeMode`) with [Field 1인당 참가비 *] nested under 유료, [CountCell 방문 횟수 제한 (회)] (0 reads 제한 없음)
        └─ 8 [Step8] 확인 — one [CellGroup] of edit [Cell]s (owner 2026-09-27 row-grammar unification: `WizardReviewRow`
           retired, matches the session review's grammar; K2b: the swatch, logo thumbnail and 기본값 badge ride beside the
           label, one Cell per schedule with its 사용 코트 line under it) — every row's own tap is the edit affordance
           back to its step, armed with "돌아가기" → 클럽 만들기 submit

States.

  • Write failures (ruling EP-3): 만들기 says its refusal on the footer helper; the name check says rate_limited_club_name_probe on the name field and never reads a refusal as "name available"; never a Toast.

  • An ordinary fresh draft silently resumes with one action-toast ("쓰던 내용이 있어요" / 처음부터 쓰기, the kit's words); ordinary stale drafts expire by TTL. Issued creation intents and committed setup progress survive that TTL, hydration, and remount, and show the receipt rather than offering a fresh create or start-over toast.

  • Per-step validity (isClubStepValid) makes the primary soft, never disabled: a press while soft stays on the step and sets attempted, which the step shows as the first missing field's line (steps 1, 2, 3, 4, 5, 8). Edit-from-review relabels it to "확인 화면으로 돌아가기", the back pill reads "확인" and the skip is hidden. An active field buffer (name, dues, description, guest fee) flushes before Next; there is no 저장 footer and no panel scope. Step 2's venueViewport reveals target the affected row; draftForReceipts overlays live court counts into steps 5/6/8 and submit. The × asks the kit's leave guard (나갈까요? · 지금 나가면 입력한 내용이 사라져요 · 계속하기 / 나가기).

  • 활동 지역 is an answer of step 2 (ruling C1), seeded from the profile's 시/도 (region-seed.ts) while the draft has none; courts only ADD the other bases they stand in (bases.ts), a 시/도-only region takes its first court's district at submit, and a saved draft with courts and no region is repaired on resume. 빠르게 만들기 ON (U-32) gives three progress segments and sequence [0, 2, 확인] when the profile names a region, four [0, 1, 2, 확인] when it does not (stepSequenceFor(quickStart, regionKnown)). Seeded review answers retain 기본값 until edited; turning quick start off keeps those answers.

  • create-club/screen/use-club-submit.ts persists the actor, stable creation key, validated create payload, and frozen optional-setup form before any create request; a rejected storage write stops creation. A retry after a lost response/remount reuses that key and payload. club/lifecycle.ts checks the current viewer's creator/key receipt before insert (including before quota enforcement), and reconciles a same-key insert conflict; a failed recovery read is not treated as absence.

  • Once the club is known to exist, optional court/schedule/logo failure retains that club and its completed checkpoints. The receipt names incomplete stages and permits manual retry; it does not report total creation failure or reset to a second create. Checkpoint persistence failure also retains recovery state rather than silently claiming setup complete.

  • Optional retries use fresh, fully paged venue and regular-meet reads in complete-club-setup.ts before creating missing follow-ups. A failed/malformed read blocks reconciliation; found IDs and completed stages are reused. The initial create, optional writes, and reconciliation reads use a captured account/token plus wizard mount/operation lease through use-club-setup-mutations.ts; an account change or unmount stops later work and navigation. Binary uploads are not queued for offline replay.

  • The receipt waits for hydration/recovery and disables retry, end, and settings actions while pending or for a different actor. Ordinary dismiss/reset callbacks recheck live intent/progress and cannot clear an issued operation. Confirmed receipt end also rechecks the actor, mount, and pending state before clearing local recovery; it does not delete the club.

Actions.

  • Ordinary draft: per-step edits; the trash on a court row removes it at once with a 되돌리기 Toast that restores the court with its schedules and fee; 문양 쓰기 on the photo row switches to the mark with a 되돌리기 Toast; 다음 일정 추가 commits the round and opens a fresh form; 빠르게 만들기 → applyQuickStartDefaults; 건너뛰기 on steps 5 (회비) and 6 (소개); header X → destructive-exit confirmation for the editable draft; step dots → any reached step; review row → its step with return-to-review armed.
  • Final CTA → useClubSubmit.handleSubmit: await durable intent, create/recover the keyed club, then complete optional setup. Only complete setup for the current operation resets the draft, shows success, and navigates to that club.
  • Receipt retry → resolve the original creation result and/or resume incomplete follow-ups from the persisted form and checkpoints. It retains the receipt on failure; retry does not mint a new creation key.
  • Receipt end → explicit confirmation, with copy warning when creation may already have happened but its result is unconfirmed; clears only local recovery after live guards pass. A committed receipt can open club settings to finish there; back returns to the known club, or to the clubs list when no club ID is confirmed.

2026-11-02 (W4 batch 3, L4-02 lane A). Steps 1–3 and the shell rebuilt to the forms board (flat-v2-forms#cc-ideal): WizardShell title per step, the soft primary that validates on press, the danger helper, the kit's leave guard and resume Toast; the tone pad, HueSlider (→ HueBand), ClubCardMiniPreview, DecorSection, VenueMeetCard, the schedule edit panel (EditPanel and its 저장 scope), HomeCourtToggle, the region fallback of step 3 and the primaryHint family are gone. Per-round deletion of a committed schedule is not drawn on the board and was dropped (flipping the fork to 아직 없어요 clears them all).

Notes. (Superseded 2026-11-08 for steps 5 and 6: the receipts are a disclosure Cell and one 적용 row.) 2026-09-28 (owner: "don't we have a canonical pattern for collapse/expand? … I would stay away from text-based control buttons at all"): EXPAND-TOGGLE — Step5(인원)'s recruitChainReceipt and Step6(회비)'s dues-suggestion receipt both drop the tinted "왜 이 숫자죠?" DisclosureLink text link (now a deleted component) — each receipt's headline RESULT row is the accordion HEADER itself (packages/ui/src/expand-toggle.tsx's ExpandToggle wraps it), a trailing chevron the only cue, pointing down at rest and turning up 180° once opened; the arithmetic beneath (ExpandReveal) is unchanged, still closed by default. Step4(스타일)'s TierRangeSelector federation/league credential detail (U-21) shares the same primitive as a lone chevron (no header content to wrap).

The draft lives in usePersistedWizardDraft (2026-09-30): every edit still persists at once, but never from inside a React state updater; the draft-to-store mapping is create-club/draft-to-form-patch.ts. Nothing renders differently.

Gaps. The club and optional setup are separate writes; the receipt makes partial completion visible, but venue/meet read-then-create reconciliation is not one atomic transaction with concurrent writers. Creation recovery uses the current actor's RLS-visible receipt, not a permanent unrestricted receipt journal. Browser/native acceptance of durable storage, lost-response/remount recovery, account switches, and picked-image URI lifetime remains pending; a vanished local image may require finishing through settings. This is source verification, not a live creation or upload run.


Join Club (클럽 가입하기) ​

  • Route: /join-club
  • Screen: JoinClubScreen — packages/features/clubs/src/join-club-screen.tsx
  • Shell: DetailShell · title t().clubList.joinWithInviteCodeAction ("초대 코드로 가입") · headerRight: none
  • Design: unreviewed
  • Verified: 2026-11-12

Purpose. The app's invite-code entry (L4-04, flat-v2-forms#jc-ideal): the visitor home is where a club's fee, policy and join are, so a signed-in visitor sees no club row here. 확인 resolves the code to a club and pushes that club's home as a visitor sees it, carrying the code. Signed out (the public web join link renders this screen) the same action reads 로그인 and returns here with the code and the club the link named. R-5 (board flat-v2-auth#jn-out): a signed-out visitor with a plausible code is first told which club it is, above the code field, so the sign-in is not blind.

Anatomy.

├─ [DetailShell] title="초대 코드로 가입" onBack footer=[CtaBand]✦확인 | 로그인 | 확인하는 중...
└─ [FlatColumn top=$6 divider=false]
    ├─ signed out with a preview only: [YStack club.join-code.preview]
    │    ├─ [MediaRow kind=club] thumb = mono initial (no logo) · l1 = name · l2 = district (else region), level band, 활동 멤버 N명
    │    └─ [Cell static] 가입 방식 = 바로 가입 | 승인 후 가입 | 초대로만 가입 (left out when the server states none)
    └─ [Field label="초대 코드" required error=▸lookup line][Input] (maxLength 10, autoCapitalize characters, autoFocus)

States. Untouched, typing (ready from 4 characters), unknown code, resolving, signed out, offline, mismatch and lookup failed as before. New:

  • Signed out, preview shown: the code in the Input has settled (debounced), is 4 to 10 characters and the session has finished restoring; the anonymous read get_club_invite_preview answered with the club's public facts. A preview of an earlier code is never shown for a later one.
  • Signed out, no preview (unknown code, archived, the day's global cap spent, offline, a failed read, a code still too short or still being typed): exactly the page without a preview. None of these is an error line.
  • Session still restoring: nothing is asked and nothing is shown (loading is not signed out).
  • Signed in, lookup limited (15 an hour per person, hint rate_limited_invite_lookup): the Field's own line '코드를 너무 자주 확인했어요. 한 시간쯤 뒤에 다시 해 주세요'; the page stays open, the code stays, no Toast.

Actions. Type (normalizeInviteCodeInput); 확인 → useInviteCodeLookup (query useClubByInviteCode(submitted), RPC lookup_club_by_invite_code, signed in) → appRouter.push(routes.club(clubId, { code })); signed out → routes.loginReturnTo(routes.joinClub({ clubId, code })); header back. The preview (useClubInvitePreview, RPC get_club_invite_preview, anon and signed in, server cap 2000 charged calls per Korea day for everyone) is asked only signed out, one ask per settled code, never retried. After sign-in the existing flow continues unchanged. The visitor home's join (useJoinWithInviteCode) looks the code up itself (the same cached query) when the page was opened with a code the lookup screen never resolved, because the server joins by code only for a club the person found through the lookup; a join refused by its hourly limit (5 an hour, hint rate_limited_invite_join) says '가입 시도가 너무 많았어요. 한 시간쯤 뒤에 다시 해 주세요' in the band's helper.

Privacy. An invite code is a secret between a club and the people it invites. Both code-keyed queries (preview, lookup) sit under the memory-only clubInviteCode cache root (never persisted, query-persist-policy) and carry SECRET_QUERY_KEY_META: the query cache's error hook logs and reports only the key's root. The preview returns no club id, logo, description, member or code.

Gaps. The preview shows no fee (the board's 월 회비 Cell is not in the preview's allow-list: dues are not a public discovery fact). The cap is global: one abuser can spend it and turn the preview off for the day (the page degrades to today's); per-IP fairness stays the later step. A club that is not discoverable is readable by the visitor home only from the cache the lookup seeds (unchanged). Entries persisted under the old clubs/by-invite-code key by earlier builds are not swept.


Leaderboard Share Redirect ​

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

Purpose. Universal-link receiver for club leaderboard share links: resolves the club and replaces to its 기록 pane (routes.club(id, { tab: 'records' })); it never renders a leaderboard itself. It takes the shared frame of the four share links.

Anatomy.

├─ Header (back, no title)
└─ one of
   ├─ loading → Hero and four Cell skeletons
   ├─ not found → [EmptyState notfound] t().common.clubNotFound · 홈으로 가기
   ├─ offline → [EmptyState offline] + retry
   ├─ error → [EmptyState error] naming 클럽 + retry
   └─ resolved → replace routes.club(club.id, { tab: 'records' })

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. The board says a non-member's /lb opens the club page (with its public 소식); today every viewer opens the 기록 pane (kept as it was, ruling E11); nothing ran on a device.


Club Dues Plan (회비 설정, L4-14) ​

  • Route: /clubs/[clubId]/dues/plan
  • Screen: ClubDuesPlanScreen — packages/features/clubs/src/club-dues-plan/club-dues-plan-screen.tsx
  • Shell: DetailShell · title t().clubSettingsScreen.duesSettings · sub the club name · footer the page's CtaBand (저장, or 만들기 on a new one-off) · header ⋯ (청구 중단) on a charged one-off
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: A ?planId= that names no active one-off is not found and offers 회비 설정.

Purpose. The club's dues plans: the one recurring plan, edited as one form, and the one-offs (한 번, owner decision 4): a named charge with an amount and a due date, asked once. Officials only (canManageDues). 일정마다 is not built and is not offered.

Now. ClubDuesPlanScreen folds the club, its members and useClubDuesPlan into moneyPageGate, then PlanRoute picks the form: no ?planId= → the recurring plan's PlanPage (with OneOffList, the Section 한 번 회비 under its rows, each a nav row into ?planId=); choosing 한 번 in the cadence Sheet turns the page into a NEW one-off's form (OneOffPage) and saves nothing (choosing a recurring value goes back to the recurring form); ?planId= of an active one-off with rows (plans[].generated) → ChargedOneOffPage; of one with no rows → OneOffPage prefilled (the retry path of a charge that failed); of anything else → a not-found state. The new one-off asks name (at most 40), amount and 납부 기한 (a DaySheet from today to the end of next year, the clock injected); the 대상 row says 지금 N명에게 청구돼요 (the active members; a new one-off has no rows yet). 만들기 opens the Dialog 총회 회식비를 만들까요? saying 지금 활동 중인 멤버 모두에게 25,000원씩 회비를 알려 드려요. 지금은 N명이에요 (the count is a reading taken before the press, not a promise: the server charges whoever is active at that instant); its commit is ONE flow of two writes (runCreateSteps): save_dues_plan (cadence one_time, startsOn today, with an id the page made before the first save), then generate_club_dues for the saved plan (useGenerateDues with { clubId, year, month, planId }). The page makes the new one-off's id once per creation (the app's request-id helper), keeps it for the whole flow and sends it on every save, so a save whose answer was lost lands on the same plan when retried (the server inserts with the id, or updates the plan holding it); the saved plan is also kept in a ref the moment step one lands, so a retry after step two failed runs step two alone (the generator is idempotent per member). A failure is the Dialog's own line and it stays open: a failed save says 저장하지 못했어요 (다시 시도 saves again), a refusal with the hint dues_plan_has_rows says the amount and date cannot change, a failed charge says the dues were saved and nobody is told yet (다시 시도 charges; 닫기 leaves, the one-off being in the list). A charged one-off locks 금액, 납부 기한 and 대상 (plans[].generated is "has a dues row"); the name stays editable and its save sends the stored amount, start and due date unchanged (renameInput: the server defaults startsOn to today when it is omitted and refuses a changed one). The header ⋯ holds 청구 중단 (danger) → Dialog 총회 회식비 청구를 중단할까요? / 이미 만든 청구는 그대로 남아요 → useDeactivateDuesPlan (errorSurface: 'dialog'; a failure is the Dialog's line).

Anatomy.

├─ [DetailShell] title=회비 설정 sub={club} onBack footer=[CtaBand]
├─ ▸ recurring → [PlanRows] / [PlanCreation] (unchanged; 납부 주기 → [ValueSheet] 매월 · 분기 · 6개월 · 매년 · 한 번)
│   └─ ▸ one-offs → [SectionHead] 한 번 회비 "2" → [CellGroup] [Cell nav] 총회 회식비 "25,000원" line2 "9월 30일까지" › ?planId=
├─ ▸ 한 번 chosen (new, or never charged) → [OneOffBody], footer [CtaBand 만들기 · soft until complete]
│   ├─ [CellGroup] [PickerCell] 납부 주기 "한 번" · [PickerCell] 납부 기한 (→ [DaySheet] min today) · [Cell] 대상 "전체 멤버" line2 "지금 19명에게 청구돼요"
│   ├─ [Field] 이름 [Input limit 40] · [AmountField] 금액 [Input unit 원]
│   └─ overlays: [ValueSheet] 납부 주기 · [DaySheet] 납부 기한 · [Dialog] 만들기 (counts, retries)
├─ ▸ charged one-off → [CellGroup] [Cell static] 납부 주기 "한 번" · [Cell edit] 이름 (→ [EditFieldModal]) · [Cell locked] 금액 · 납부 기한 · 대상;
│   footer [CtaBand 저장 · soft until the name changes]; header ⋯ → [MenuSheet] 청구 중단 (danger) → [Dialog] 중단
└─ ▸ gate → as before; unknown `?planId=` → [NotFoundState] 회비를 찾을 수 없어요

States. Recurring saved / first plan (unchanged); 납부 주기 Sheet of five; one-offs Section (none, one, several); new one-off (empty, each missing answer's line in order: 이름, 금액, 납부 기한); 납부 기한 Sheet; the create Dialog (idle, working, save failed, locked, charge failed); charged one-off (name editable, three locked, 저장 soft, a refusal line); 청구 중단 (menu, Dialog, working, failed); one never charged (prefilled form); not found; the gates as before. A changed one-off form arms the edit guard; choosing 한 번 over an unsaved edit of the recurring form asks the same guard Dialog first.

  • Offline (F1c): the band's primary, the ⋯ row and the Dialog commits are writes the kit holds; nothing is hidden by hand.

Actions. 납부 주기 → value Sheet (한 번 ⇄ recurring switches the form, nothing saved) · a one-off row → its page · 납부 기한 → day Sheet · 만들기 → Dialog → save_dues_plan then generate_club_dues · 이름 → editor, 저장 → save_dues_plan (rename) · ⋯ 청구 중단 → Dialog → deactivate_dues_plan · back (edit guard while changed).

Gaps. The 납부 기한 Stepper wraps at 200% text on the recurring page (a kit defect, not worked around). The one-off's page is ?planId= on the plan route (routes.clubDuesPlan(clubId, planId)). A recurring plan's amount change updates the plan only (periods already made keep the old amount, later ones use the new): the board has no line for it, so the page says nothing. A one-off stopped with a charge that was never made leaves no trace on this page. The "now N members" count is the client's reading of the active members; the server charges whoever is active when the call lands, so the Dialog words it as a reading. ​

Club Ledger (장부, L3-46) ​

  • Route: /clubs/[clubId]/dues/ledger
  • Screen: ClubLedgerScreen — packages/features/clubs/src/club-ledger/club-ledger-screen.tsx
  • Shell: DetailShell (scroll={false} over a FeedList) · title 장부 · sub the club name · header ⋯ → 지출 추가 · 시작 잔액 설정 (고치기 once it exists) · 장부 내려받기 · 시작 잔액 지우기 (danger, last)
  • Design: unreviewed
  • Verified: 2026-11-13
  • Batch 58 (ST1, 2026-11-13), States: Same order, with the offline class hidden: another member's money, a staff queue or staff analytics is never drawn from a warm copy offline (the offline state instead). 가입 심사's request another official answered is the helper's not found and offers 신청 목록; a malformed id offers 클럽으로 or 신청 목록.

Purpose. The club's money in and out for its officials, newest first by month (club_ledger_entries; list_club_ledger, get_club_ledger_summary, add_ledger_entry, update_ledger_entry, delete_ledger_entry, export_club_ledger; migrations 00706 and 00751). Officials only (canManageDues). A leaf of 회비 관리's 장부 Cell. The two tiles say where the club stands (the balance, then what this month spent); each entry opens its own Sheet. The balance is the opening balance plus every entry dated on or after the opening's day; the server marks each line counted, and the page draws ONE head, 잔액에 들어가지 않는 기록, over the run of counted: false lines (older than the opening) and never derives that from the dates. An expense is dated by the officer (오늘 until another day is picked, never after today), may carry a receipt photo, and the whole ledger can be downloaded as a CSV (never imported). Built to flat-v2-list-a#lg-*.

Anatomy. ClubLedgerScreen folds the club's own reads into moneyPageGate and draws LedgerPage, which reads useClubLedger (infinite, a day cursor) through useLedgerList. The opening comes from the summary (get_club_ledger_summary and the first page's tiles carry opening: id, amount, day or null), so the ⋯ chooses 설정 or 고치기 without paging.

├─ [DetailShell] title=장부 sub={club} onBack actions=[⋯] (none while cold or failed)
│   └─ ⋯ rows, all `write` (held offline): 지출 추가 · 시작 잔액 설정 | 고치기 · 장부 내려받기 (only where the device can share a file) · [danger group] 시작 잔액 지우기 (with an opening)
├─ [FeedList]
│   ├─ header: 12 · [SectionHead first] "9월 장부" · [Strip stackLarge] 잔액 · 이번 달 지출
│   ├─ per month: [SectionHead] "9월" + [TextRow] title / day · 분류 · 영수증 / ±amount (the opening line "시작 잔액 / 9월 1일 / +940,000원" sits in its month)
│   ├─ then, when any line is `counted: false`: [SectionHead] 잔액에 들어가지 않는 기록 + its [TextRow]s (no month heads)
│   ├─ footer: three skeleton [TextRow] while the next page loads
│   └─ states: skeleton tiles + six rows · [EmptyState first] "시작 잔액을 먼저 정해 주세요" / "정한 날부터 입금과 지출이 쌓여요" (no tiles; the one action 시작 잔액 설정 opens the OpeningSheet the ⋯ opens) · [QueryErrorState] · [OfflineEmptyState]
├─ [EntrySheet] bleed: 금액 · 날짜 · 분류 · 영수증 [Cell nav, `write`: opens the photo]; an expense also 수정 · 삭제 (destructive last); income and opening: facts only
├─ [ReceiptSheet] 영수증: the photo from a fresh signed link (`useLedgerReceiptUrl`, 5 minutes, never persisted: the `ledger/receipt` scope is persist-denied); [SkeletonKind hero] while it loads; a danger line when it fails
├─ [ExpenseSheet] bleed, 지출 추가 / 지출 수정: rail: 항목 [Field Input limit 60] · 금액 [Input unit 원] · 분류 [ChoiceSet]; rows: 날짜 [PickerCell → DaySheet, no day after today] · 영수증 [Cell nav 추가 → album | Cell choice with a Thumb → MenuSheet 바꾸기 · 지우기 (danger)]; footer 추가 / 저장 (soft until it can write); helper = the server's refusal line or 저장하지 못했어요
├─ [OpeningSheet] bleed, 시작 잔액: rail: [Field "기준일 시작 때 잔액" Input unit 원] (no helper line); row: 기준일 [PickerCell → DaySheet]; footer 저장. A fix asks first: [OpeningChangeDialog] "시작 잔액을 고칠까요?" / "고치면 잔액이 60,000원 늘어요" · 닫기 · 고치기
├─ [OpeningRemoveDialog] "시작 잔액을 지울까요?" / "지우면 모든 기록이 잔액에 들어가고, 잔액이 645,000원 줄어요" · 닫기 · 지우기 (danger); a failure: "기록을 삭제하지 못했어요" · 닫기 · 다시 시도
├─ [DownloadSheet] ValueSheet 장부 내려받기: 이번 달 · 올해 · 전체; a tap starts the file (web: Blob download; phone: written to the cache, then the OS share sheet); a failure: [FailureDialog]
└─ [DeleteEntryDialog] "{title} 기록을 삭제할까요?" / "삭제하면 잔액이 {amount} 늘어요" · 닫기 · 삭제 (danger); a failure: "기록을 삭제하지 못했어요" (the server's own refusal when it named one) · 닫기 · 다시 시도

States. Ideal; with an opening (the older run under its one head); empty (names the opening first); loading; load error; cold offline; stale (cached entries and the balance readable under the root strip, every ⋯ row and an entry's 영수증, 수정 and 삭제 held by the write gate; no receipt photo is cached); long titles wrap to two lines with the amount on one; the next page as skeleton rows; the wall for a viewer without canManageDues; club not found; an add, an edit or the opening in flight (the button reads 저장 중...) and failed (the footer's danger helper: 오늘 이후 날짜는 고를 수 없어요 · 시작 잔액이 이미 있어요 · 고칠 수 없는 기록이에요 · 장부는 대표와 총무만 고칠 수 있어요 · 이미 지워진 기록이에요, else the general line); a delete or an opening removal in flight (the Dialog is committing) and failed; a download failed (a Dialog); a receipt link loading and failed.

  • Offline (F1c, the app's root strip says it): the ledger stays readable from memory while the app stays open, like 회비 관리's roster; neither is written to the disk cache (PERSIST_TABLE: ledger and the officials' dues period rows are persist: false, EP-2), and the receipt's signed URL, a bearer link, is never kept at all.

Actions. Back · pull to refresh · the next page on reaching the end · an entry → its Sheet → 영수증 → the photo · 수정 → the Sheet filled in → 저장 · 삭제 → the Dialog → 삭제 · ⋯ → 지출 추가 → 항목, 금액, 분류, 날짜, 영수증 → 추가 · ⋯ → 시작 잔액 설정 → 금액, 기준일 → 저장 · ⋯ → 시작 잔액 고치기 → 저장 → the Dialog → 고치기 · ⋯ → 시작 잔액 지우기 → the Dialog → 지우기 · ⋯ → 장부 내려받기 → a range.

Gaps. (1) The board's 사진 찍기 source (lg-receipt-source) is not built: expo-image-picker has no camera usage description or plugin in apps/mobile/app.json, and the web shim has no camera; 추가 opens the album directly (a native config change for the W5 build). (2) The receipt photo is downscaled and re-encoded by the picker before upload on a phone (the manipulator writes pixels only) and, on the web app, through a canvas (image-resize.web, R10), so neither keeps location data; a real browser has not run the web path (docs/release-evidence.md, S20). (3) 날짜 is the kit's day Sheet, a month calendar whose later days are off (the board is drawn with it). The 영수증 thumbnail is the picked photo only; a stored one shows the photo glyph. (4) The device's today stands for the club's local today (a club has no time zone field in the app); the server decides, and a refusal across the date line is the typed line 오늘 이후 날짜는 고를 수 없어요. (5) The opening's id, amount and day are the summary's, so the page never pages for them; the effect of a fix or a removal is named only when the pages read hold every entry it depends on. (6) An amount change with the day unchanged is named exactly (the difference); a day change or a removal is named only when the pages read hold every entry between the days, else the Dialog says only that the balance changes. (7) 시작 잔액 설정 is the app's word (the board's 정하기 trips the house-style 하기 rule). (8) 장부 내려받기 on a phone needs a build that holds expo-sharing and expo-file-system (the native fingerprint changes; W5); the native path is untested (docs/release-evidence.md, S18). The camera source for a receipt waits for the same build (S19). The month head is always the current month, not a browsed one.

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