Detail pages — seam & structure audit (pre-implementation)
Status: Accepted · Date: 2026-09-06 · Purpose: page-by-page audit of the flat detail pages against the seam ladder, two rails, edge rule, divider law, row-group rule, information-block rule, badge placement, affordance law, and hero metrics — run before the implementation pass, at the owner's request. Implementation order is at the end.
Scope covered in depth: all primitives in packages/ui/src/{flat-column,flat-column-defaults,section-block,section-header,section-header-metrics,list-row,list-row-visual,row-list,row-list-visual,fact-row,fact-row-visual,stat-strip,stat-strip-visual,stat-tile,well,well-tone,callout,banner,media-hero,media-hero-visual,type-hero,type-hero-visual,action-icon-row,action-icon-row-visual,bottom-cta-band,detail-shell,shell-ground,app-header,collapsing-top-bar,empty-state,skeleton-flat-page,box-sizes,tokens}; the 9 screens named plus their section/part files that compose the page shell (session-detail full section set; directory-venue-detail screen + all 13 section renderers + host-confirm/sources/pending-review; venue-detail-screen; match-board-screen + all 4 sections + unsettled-pool-notice; record-detail-screen + match-timeline-row/opponent-context-card/outcome-summary-card; profile-screen + friends-summary-card + achievements-preview-section + attendance-section + manner-tags-section + profile-menu-card; public-profile-screen; join-review-detail-screen; public-club-profile-screen + join-cta-band; post-detail-screen + post-detail-header + post-action-row). Sub-parts not individually enumerated below (e.g. directory-venue-detail/parts/*, match-board-overlays.tsx, activity-card-section.tsx, gallery-card-section.tsx, guest-sessions-section.tsx, record-day-detail-screen.tsx, comment-row.tsx, author-row.tsx, comment-composer.tsx) were spot-checked for the self-gating pattern only (grep "return null"), not read line-by-line — flagged as "inherits" rather than given their own row.
A recurring shorthand is used below: [P1]…[P8] refer to the eight primitive-level defects detailed in the cross-page table. Read that table first — most page rows just cite which [Pn] they inherit plus their own local finding.
Cross-page primitive findings (fix once in @twomore/ui)
| # | Severity | File:line | Rule | What's there | What the rule requires | Fix | Pages that inherit it |
|---|---|---|---|---|---|---|---|
| P1 | High | packages/ui/src/flat-column.tsx:62-63 (React.Children.toArray(children)) | D | resolveFlatColumnSeparators sizes the divider array off items.length from React.Children.toArray. That call only sees the JSX element graph — it cannot know a custom component (e.g. NoticeSection, LiveParticipantBar) will internally return null. Every unconditionally-passed, self-gating section counts as "1 item" for divider math even when it renders nothing. | "full-bleed hairline only between sections… an absent section never leaves a doubled line" (the primitive's own doc comment, flat-column.tsx:19-22) | Either (a) require every FlatColumn child to be gated at the call site with cond ? <X/> : null (never an internally-self-gating component), and lint for it; or (b) give sections a visible boolean the screen checks before spreading them into FlatColumn's children. | session-detail (NoticeSection, LiveParticipantBar, RefundBanners, PaymentGateBanner, HostCalloutSection, HostToolsSection, DangerZoneSection — 7 unconditional self-gating children), directory-venue-detail (13 section renderers wrapped in a keyed <React.Fragment>, all self-gate on empty data — the fragment is transparently flattened by toArray, so the underlying component is still opaque), match-board (UnsettledPoolNotice, AllDoneBanner), friends-summary-card / achievements-preview-section (self-gate on isLoading, transient) |
| P2 | High | packages/ui/src/flat-column-defaults.ts:15 (FLAT_COLUMN_DEFAULT_TOP = '$3') | A | FlatColumn's top (hero bottom → first section) defaults to $3 = 12px, and the doc comment explicitly folds "hero bottom → first section" into the "chrome→content" (M=12) seam. | Rule A lists "hero identity→first section" under L=16, a different named seam from "chrome→content" (M=12). | Split the two seams: keep $3 for genuine chrome→content, add a distinct $4 default (or override) for the hero→FlatColumn seam. | Every page using MediaHero/TypeHero + default FlatColumn (all 9 screens) |
| P3 | High | packages/ui/src/flat-column-defaults.ts:22 (FLAT_COLUMN_DEFAULT_GAP = '$5') | A | FlatColumn's gap (section→section, also space either side of the divider) defaults to $5 = 20px. | Rule A's ladder is explicitly $1/$2/$3/$4/$6/$9 = 4/8/12/16/24/48 — there is no $5/20 rung. Rule A names "either side of the full-bleed section divider" as XL=24. | Change the default to $6 (24px). | Every page using default FlatColumn (all 9 screens) |
| P4 | High | packages/ui/src/stat-strip.tsx:51-59 | D, F | Each StatStrip cell renders paddingTop="$2" paddingBottom="$1", a fixed minHeight={56}, and borderLeftWidth={hairline?1:0} (every cell but the first). | Rule F: "StatStrip = equal columns with an 8 gap, no vertical hairlines… no cell padding/min-height (seams owned by the column)." Rule D: "nothing frames a…StatStrip." | Drop the per-cell border/padding/min-height; add gap="$2" on the outer XStack instead. Separately, StatStrip should never be a plain FlatColumn child that gets a leading/trailing divider (see P1's sibling issue below) — every page in this audit places StatStrip where it gets framed on at least one side. | session-detail, directory-venue-detail, venue-detail, record-detail, profile-screen (×2 strips), public-profile — i.e. every page that uses StatStrip |
| P5 | High | packages/ui/src/fact-row.tsx:53-58 (ROW_STYLE.paddingVertical: '$2') and packages/ui/src/list-row.tsx:118 (paddingVertical: metrics.paddingVertical) | B | Every FactRow/ListRow — including the first and last row of a group — carries the same fixed vertical padding ($2=8, $3 dense variants etc.). Neither primitive is aware of its position. | Rule B: "a section's first child carries no top padding and its last child no bottom padding (row padding is only BETWEEN rows)." Under SectionBlock variant="flat", the header already owns paddingBottom="$2" for the title→content seam (S=8); the first row's own paddingVertical top half adds another 8px on top, doubling the seam to 16px. | Give FactRow/ListRow (or their FactList/RowList wrapper) first/last-aware padding, same shape as resolveFlatColumnSeparators. | InfoSection/HostToolsSection (session-detail), all FactList sections (venue-detail, public-profile, join-review-detail, public-club-profile, attendance-section), OpponentContextCard/ProfileMenuCard (RowList of ListRow) |
| P6 | Medium | packages/ui/src/bottom-cta-band.tsx:26 (paddingTop="$3") + every page that hardcodes contentContainerStyle={{ paddingBottom: 48 }} on its manual ScrollView | A | BottomCtaBand's own top inset is $3=12. Rule A groups "band padding" under L=16. Separately, session-detail-screen.tsx:328, directory-venue-detail-screen.tsx:260, venue-detail-screen.tsx:297, public-profile-screen.tsx:306 all hardcode paddingBottom: 48 on the scroll body regardless of whether a BottomCtaBand is actually mounted below it. | Rule A: XXL=48 is "scroll tail with no band"; when a band is present the content→band seam should be L=16. directory-venue-detail-screen.tsx is the worst instance — its CTA band is unconditional, so the seam is always wrong (48 instead of 16). | Make BottomCtaBand's top padding $4; make the screens' paddingBottom conditional on whether the band renders (hasBookingAlert, primaryAction, etc.). | session-detail, directory-venue-detail, venue-detail, public-profile-screen |
| P7 | High | packages/ui/src/detail-shell.tsx:105-145 (scroll prop, default true) | — (structural, undermines A/H) | When a screen doesn't pass scroll={false}, all of children — including a BottomCtaBand the screen intends to pin — is wrapped inside DetailShell's own single <ScrollView>. Nothing inside DetailShell separates "scrollable body" from "pinned band"; that split only happens if the caller opts out and renders its own ScrollView + a sibling BottomCtaBand, the pattern session-detail-screen.tsx, directory-venue-detail-screen.tsx, venue-detail-screen.tsx and public-profile-screen.tsx all correctly use. | Three screens skip this: match-board-screen.tsx (default scroll=true, BottomCtaBand at line 276 is a plain child), join-review-detail-screen.tsx (scroll passed explicitly truthy, line 269, band at line 295), public-club-profile-screen.tsx (no scroll prop at all, JoinCtaBand→BottomCtaBand at line 538). Their own doc comments call these bands "pinned"/"the one pinned page action" — they are not; they scroll away with the content. | Either make DetailShell accept a dedicated footer slot it renders outside the ScrollView itself (removing the need for every page to hand-roll the split), or add scroll={false} + manual ScrollView to the three screens, matching the other six. | match-board-screen.tsx, join-review-detail-screen.tsx, public-club-profile-screen.tsx |
| P8 | Low | packages/ui/src/type-hero.tsx:11-12 vs :51 | A (doc drift) | Doc comment: "owns the page-chrome→content seam ($2…)"; code: paddingTop="$3". | The code's $3=12 is actually correct per rule A's M=12 "chrome→content" — the comment is stale/wrong, which is exactly the kind of drift the seam-ladder law exists to prevent. | Fix the comment to say $3. | Doc-only; no visual bug |
Per-page findings
session-detail-screen.tsx + session-detail/sections/*
| Sev | File:line | Rule | What's there | What's required | Fix |
|---|---|---|---|---|---|
| High | session-detail-screen.tsx:358-477 | D | FlatColumn (default divider) wraps StatStrip, then LiveParticipantBar, RefundBanners, NoticeSection (Well), PaymentGateBanner, ParticipationSection, MatchesSection, InfoSection, HostCalloutSection, HostToolsSection, DangerZoneSection — 7 of these 10 self-gate to null internally (see P1) and NoticeSection is a Well (see P4/D "nothing frames a Well") | No orphan dividers; StatStrip/Well never framed | Apply P1 fix; gate NoticeSection/LiveParticipantBar/RefundBanners/PaymentGateBanner/HostToolsSection/DangerZoneSection with call-site ternaries instead of internal self-gates |
| Medium | session-detail-screen.tsx:328 | A | contentContainerStyle={{ paddingBottom: 48 }} always, even though primaryAction→BottomCtaBand (line 482-490) frequently renders | P6 | Conditional paddingBottom |
| Medium | session-detail/sections/host-callout-section.tsx:44-58 | A | Returns a bare <>{conditionalBanner}<SectionBlock>…</SectionBlock></> with zero gap between the pending-applicants Banner and the 호스트 SectionBlock when both render | Rule A: block peers = M=12 | Wrap in <YStack gap="$3"> |
| Low | session-detail/sections/refund-banners.tsx:41-65 | A | Returns <>{banner1}{banner2}</> with 0 gap when both render (rare edge case: pending refund + host overdue refund simultaneously) | Block peers = M=12 | Add gap="$3" wrapper |
| Inherits | session-detail/sections/info-section.tsx:50, host-tools-section.tsx:60-141 | B | SectionBlock variant="flat" + FactList/RowList | — | P5 |
| Inherits | session-detail-screen.tsx:358 | A | Default FlatColumn | — | P2/P3 |
directory-venue-detail-screen.tsx + sections/* + parts/*
| Sev | File:line | Rule | What's there | What's required | Fix |
|---|---|---|---|---|---|
| High | directory-venue-detail-screen.tsx:266 | A | <YStack gap="$2"> hand-wraps MediaHero + ClosedVenueBanner, entirely outside FlatColumn's seam ownership | One owner per seam; hero→first-visible-content should be L=16 (or M=12, never a bespoke $2=8) | Fold the closed-banner into FlatColumn's own top seam instead of a second ad hoc YStack |
| High | directory-venue-detail-screen.tsx:260 | A | paddingBottom: 48 even though BottomCtaBand (line 331) is unconditional on this screen | P6, worst instance since the band always renders | Change to paddingBottom: 16 ($4) |
| High | directory-venue-detail-screen.tsx:311-326 | D | state.sectionOrder.map(key => <React.Fragment key={key}>{sectionRenderers[key]}</React.Fragment>) plus <SourcesSection/>/<HostConfirmSection/>/<PendingReviewSection/> — 12 of 13 possible sections self-gate to null on missing data (aliases.tsx:23, amenities.tsx:26, review-keywords.tsx:19, fee.tsx:27, payment.tsx:18, basic-info.tsx:25, blog-reviews.tsx:20, hours.tsx:38, capabilities.tsx:21, rating.tsx:18, photos.tsx:20, sources.tsx:27, pending-review.tsx:32) | P1 — the keyed Fragment is transparently flattened by toArray, so it does not fix the orphan-divider bug | Convert every section renderer to a call-site ternary, or fix FlatColumn/toArray usage per P1 |
| Med | directory-venue-detail-screen.tsx:307 | D, F | StatStrip sits as FlatColumn's first item, immediately followed by ActionIconRow — divider renders after it | P4 | — |
venue-detail-screen.tsx
| Sev | File:line | Rule | What's there | What's required | Fix |
|---|---|---|---|---|---|
| Good pattern | :324-367 | D | Optional SectionBlocks (hours/facilities/notes) are gated with cond ? <SectionBlock>…</SectionBlock> : null at the call site — correctly avoids P1 | — | (model this pattern elsewhere) |
| Medium | :297 vs :376-388 | A | paddingBottom: 48 always; BottomCtaBand only renders when hasBookingAlert | P6 | Conditional padding |
| Medium | :257-286 | E | actionItems can shrink to 2 (Navigation+Map only) when the venue has no bookingUrl/bookingPhone | ActionIconRow documents/dev-warns a 3-5 item contract (action-icon-row-visual.ts:8-9,15-17) | Always include a 3rd action (e.g. a "정보" fallback) or drop ActionIconRow for a plain row below 3 |
| Inherits | :309 | A | Default FlatColumn | — | P2/P3 |
| Inherits | :314-359 | B | FactList under SectionBlock | — | P5 |
match-board-screen.tsx + match-board/sections/*
| Sev | File:line | Rule | What's there | What's required | Fix |
|---|---|---|---|---|---|
| High | match-board-screen.tsx:200-317 | — (P7) | DetailShell has no scroll={false}; BottomCtaBand (line 276) and MatchBoardOverlays (line 286) are plain children, so they render inside the automatic ScrollView | File's own doc comment: "the lifecycle CTA…moved off the scroll body into BottomCtaBand — the one pinned page action" — not true as built | P7 fix |
| Medium | match-board-screen.tsx:235,265 | D | <UnsettledPoolNotice/> (unsettled-pool-notice.tsx:28) and <AllDoneBanner/> (all-done-banner.tsx:34) self-gate internally but are unconditional FlatColumn children | P1 | Ternary-gate at call site |
| Inherits | :232 | A | Default FlatColumn | — | P2/P3 |
record-detail-screen.tsx + record-detail/*
| Sev | File:line | Rule | What's there | What's required | Fix |
|---|---|---|---|---|---|
| Good pattern | :110-256 | D | All optional blocks (EmptyState/StatStrip cluster/opponents SectionBlock) are ternary-gated at the call site (even the inline <>...</> fragment gets correctly flattened by toArray since it's a literal fragment, not an opaque self-gating component) — avoids P1 | — | (model this pattern elsewhere) |
| Medium | :217 | A | <YStack gap="$4"> between match-date groups inside the 매치 목록 SectionBlock | Rule A: block peers = M=12, not L=16 | Change to gap="$3" |
| Medium | :147-160 | D, F | StatStrip sits between the scope-selector row and the 활동 SectionBlock — framed by dividers on both sides | P4 | — |
| Good pattern | match-timeline-row.tsx, :236-241 | D | RowList dividers explicitly justified ("MatchTimelineRow is a multi-component block…owner ruling 2026-09-06") | Rule D explicitly allows this | Compliant |
profile-screen.tsx + achievements-preview-section.tsx + attendance-section.tsx + manner-tags-section.tsx + friends-summary-card.tsx + profile-menu-card.tsx
| Sev | File:line | Rule | What's there | What's required | Fix |
|---|---|---|---|---|---|
| High | profile-screen.tsx:295-370 | D, F | Bare <YStack gap="$1"> ELO fact is FlatColumn's first child, immediately followed by StatStrip(s) — framed by dividers before and after | P4 | — |
| Medium | profile-screen.tsx:327 | F | <YStack gap="$2"> between the two stacked StatStrips | Rule F: "two strips stacked = 12" (M), not S=8 | Change to gap="$3" |
| Medium | achievements-preview-section.tsx:121-131 | Well contract | <Well key={achievement.id} width={44} height={44} padding="$0" …> overrides Well's own fixed $3 padding | well.tsx doc: "padding $3 in every tone" (an invariant, not a per-call override point) | Either add a documented "tile" variant to Well, or use a plain YStack for these 44px icon chips instead of misusing Well |
| Low | friends-summary-card.tsx:81, achievements-preview-section.tsx:40-49 | D | FriendsSummaryCardContent/AchievementsPreviewContent return null while isLoadingFriends/before data resolves, while unconditionally spread into FlatColumn (guarded only by userId) | P1 (transient) | Low real-world impact (Protocol B pre-fetch usually avoids the loading window) but same architecture risk |
| Good pattern | profile-menu-card.tsx:44-57 | E | RowList of ListRows, no dividers, "peers…spacing only" comment | Compliant | — |
public-profile-screen.tsx
| Sev | File:line | Rule | What's there | What's required | Fix |
|---|---|---|---|---|---|
| High | :337-401 | D, F | Bare <XStack> ELO fact is FlatColumn's first child, StatStrip immediately follows | P4 | — |
| Medium | :405-409 vs :112-114 | H/D | BottomCtaBand renders whenever !isSelf && myId.length>0, but FriendshipButton returns null when friendship.status === 'blocked' — produces an empty chromed band (border + padding, no button) | Rule H: "every tappable shows one resting cue" — an empty band with no control is neither a cue nor absent | Gate the BottomCtaBand itself on friendship?.status !== 'blocked' |
| Medium | :306 | A | paddingBottom: 48 always vs conditional BottomCtaBand | P6 | Conditional padding |
join-review-detail-screen.tsx
| Sev | File:line | Rule | What's there | What's required | Fix |
|---|---|---|---|---|---|
| High | :269-320 | — (P7) | <DetailShell … scroll> (explicit truthy scroll) — BottomCtaBand (line 295) is a plain child, inside the ScrollView | Same as match-board: not actually pinned | P7 fix |
| Medium | :296 | A | <XStack gap="$2"> between the 거절/승인 buttons | Rule A: "button-pair gap" = M=12 | Change to gap="$3" |
| Inherits | :283 | A | Default FlatColumn | — | P2/P3 |
| Inherits | :284-286 | B | FactList under SectionBlock | — | P5 |
public-club-profile-screen.tsx + public-club-profile/*
| Sev | File:line | Rule | What's there | What's required | Fix |
|---|---|---|---|---|---|
| High | :362-554 | — (P7) | No scroll prop passed (defaults true) — the "pinned" JoinCtaBand (join-cta-band.tsx, itself just <BottomCtaBand>) at line 538 is a plain trailing child inside the auto ScrollView | File's own doc comment: "Application panel (Well) + pinned JoinCtaBand" — not actually pinned | P7 fix |
| Medium | :381 | B | <YStack paddingHorizontal="$4" paddingTop="$4"> around the archived Banner, before the hero, with an explicit eslint-disable-next-line @twomore/no-padding-top-in-features acknowledging it bends a lint rule | Rule B: "no block adds its own outer margin" | Give MediaHero/FlatColumn (or a dedicated leading-banner slot) ownership of this seam instead of a suppressed-lint ad hoc wrapper |
| Good pattern | :374-534 | D | Archived banner / guest-sessions / description / application-panel are all ternary-gated at the call site | Compliant | — |
| Low (unverified) | activity-card-section.tsx, gallery-card-section.tsx | D | Always rendered as unconditional FlatColumn children (unlike the other four optional blocks); not confirmed whether they self-gate to null on empty data | If they do, P1 applies | Verify; ternary-gate if so |
post-detail-screen.tsx + post-detail/*
| Sev | File:line | Rule | What's there | What's required | Fix |
|---|---|---|---|---|---|
| High (architectural) | whole file | A, D | This screen does not use FlatColumn/SectionBlock/Well/StatStrip at all — it hand-rolls a "flat hairline zone" grammar where every zone (post-detail-header.tsx:60-88, :94-116, :120-134, post-action-row.tsx:72-79) owns its own borderBottomWidth={1} + $4/$3 padding independently | Rule A: "one owner per seam." Rule D: "FlatColumn owns it." | This page sits entirely outside the audited system — either bring it onto FlatColumn+SectionBlock, or explicitly document it as an intentional second grammar (virtualized feed vs. flat detail page) so future audits don't compare it against the wrong rulebook |
| Medium | post-detail-screen.tsx:135-140 | D | renderComment applies borderBottomWidth={1} unconditionally on every comment row, including the last (no "all but last" logic RowList uses elsewhere) | Divider law's "never a trailing one" convention used everywhere else (row-list-visual.ts) | Track index === commentsList.length - 1 and suppress the trailing border |
| Low | :312 | A | contentContainerStyle={{ paddingTop: 16, paddingBottom: 48 }} — no hero on this page, so 16 stands in for the chrome→content seam | Rule A puts generic chrome→content at M=12 | Low confidence this is wrong (no hero to compare against) — flagging for owner call |
Findings count per rule letter
| Rule | Distinct findings | Notes |
|---|---|---|
| A (seam ladder) | 13 | 3 primitive (P2,P3, BottomCtaBand's own top padding), plus page instances: 4× hardcoded-48-vs-16 CTA seam (session-detail, directory-venue, venue-detail, public-profile), directory-venue ad hoc $2 hero gap, record-detail $4 group gap, join-review $2 button-pair gap, host-callout-section 0-gap, post-detail 16-vs-12 chrome seam, TypeHero doc/code drift |
| B (edge rule) | 2 | FactRow/ListRow first/last padding (P5, cross-cutting — inherited by ~8 pages), public-club-profile's suppressed-lint ad hoc paddingTop |
| C (two rails) | 0 | No violations found — $4 rail and full-bleed media are consistently correct everywhere audited |
| D (divider law) | 4 classes / ~9 page instances | P1 orphan-divider bug (session-detail ×7, directory-venue ×13, match-board ×2, friends/achievements transient), StatStrip/Well framing (P4, session-detail, directory-venue, profile×1, public-profile, record-detail = 5 instances), post-detail's independent divider ownership, post-detail's trailing-hairline bug |
| E (row groups) | 1 | venue-detail's ActionIconRow can drop below its 3-item floor |
| F (info blocks) | 2 | StatStrip primitive itself (P4), profile-screen's stacked-strip gap (8 instead of 12) |
| G (badge placement) | 0 | Every MediaHero/TypeHero use audited (session-detail, public-club-profile, profile, public-profile) correctly uses one titleBadge + ≤2 badges |
| H (affordance) | 1 | public-profile's empty BottomCtaBand when FriendshipButton is null (blocked) |
| I (hero) | 0 | 132px band / 56px avatar / pageTitle 24/32 consistent everywhere |
| Structural (P7, un-lettered) | 3 | match-board, join-review-detail, public-club-profile all fail to pin their BottomCtaBand — DetailShell's scroll default silently swallows it |
Suggested implementation order
Step 1 — Primitives (≈10-12 files, no page changes yet) Fix flat-column.tsx/flat-column-defaults.ts (P2 top→$4, P3 gap→$6, and decide/document the P1 contract — e.g. a lint rule forbidding a self-gating component as a direct FlatColumn child), stat-strip.tsx/stat-strip-visual.ts (P4: drop padding/minHeight/hairline, add gap="$2"), fact-row.tsx/fact-row-visual.ts + list-row.tsx/list-row-visual.ts (P5: first/last-aware padding), bottom-cta-band.tsx (P6: top padding $4), type-hero.tsx (P8 doc fix). Update flat-column-defaults.test.ts and any StatStrip/FactRow/ListRow snapshot tests. Files touched: ~10-12.
Step 2 — Pin the three broken CTA bands (P7)match-board-screen.tsx, join-review-detail-screen.tsx, public-club-profile-screen.tsx: add scroll={false} + a manual ScrollView wrapping everything except the trailing BottomCtaBand/JoinCtaBand/overlays, matching session-detail-screen.tsx's existing pattern. This is high-value and low-risk since 6 of 9 screens already do it correctly. Files touched: 3 (consider also extending DetailShell with a footer slot so this can't regress again — +1 primitive file if taken).
Step 3 — Eliminate P1 self-gating children now that FlatColumn's ladder is correct Convert every unconditionally-passed, internally-self-gating FlatColumn child to a call-site ternary: session-detail-screen.tsx (7 sections), directory-venue-detail-screen.tsx (13 section renderers — biggest single change), match-board-screen.tsx (2). Model on venue-detail-screen.tsx/record-detail-screen.tsx's existing correct pattern. Files touched: ~5 screen files + review of ~20 section components (most need no code change, just how they're invoked from the screen).
Step 4 — Page-level seam-value fixes (small, independent, parallelizable)session-detail-screen.tsx:328, directory-venue-detail-screen.tsx:260, venue-detail-screen.tsx:297, public-profile-screen.tsx:306 (conditional paddingBottom); record-detail-screen.tsx:217 ($4→$3); join-review-detail-screen.tsx:296 ($2→$3); session-detail/sections/host-callout-section.tsx:44-58 (add gap="$3"); directory-venue-detail-screen.tsx:266 (fold the closed-banner gap into FlatColumn's own seam); profile-screen.tsx:327 ($2→$3). Files touched: ~9.
Step 5 — Remaining one-offsvenue-detail-screen.tsx (ActionIconRow ≥3-item guarantee), achievements-preview-section.tsx (replace misused Well padding="$0" tiles), public-profile-screen.tsx (guard BottomCtaBand on non-blocked friendship), post-detail-screen.tsx (trailing-hairline fix; flag the whole-file architectural deviation for an owner decision rather than a mechanical fix), public-club-profile-screen.tsx's archived-banner padding, and verification reads of activity-card-section.tsx/gallery-card-section.tsx for hidden P1 risk. Files touched: ~6-8.
Total estimated footprint: ~35-45 files across 5 steps, with Steps 1-2 carrying the highest leverage (fix the shared primitives and the three genuinely-broken CTA bands before touching any per-page seam value, since several page-level "fixes" in Step 4 are only meaningful once P2/P3/P6 land).