Skip to content

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)

#SeverityFile:lineRuleWhat's thereWhat the rule requiresFixPages that inherit it
P1Highpackages/ui/src/flat-column.tsx:62-63 (React.Children.toArray(children))DresolveFlatColumnSeparators 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)
P2Highpackages/ui/src/flat-column-defaults.ts:15 (FLAT_COLUMN_DEFAULT_TOP = '$3')AFlatColumn'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)
P3Highpackages/ui/src/flat-column-defaults.ts:22 (FLAT_COLUMN_DEFAULT_GAP = '$5')AFlatColumn'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)
P4Highpackages/ui/src/stat-strip.tsx:51-59D, FEach 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 hairlinesno 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
P5Highpackages/ui/src/fact-row.tsx:53-58 (ROW_STYLE.paddingVertical: '$2') and packages/ui/src/list-row.tsx:118 (paddingVertical: metrics.paddingVertical)BEvery 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)
P6Mediumpackages/ui/src/bottom-cta-band.tsx:26 (paddingTop="$3") + every page that hardcodes contentContainerStyle={{ paddingBottom: 48 }} on its manual ScrollViewABottomCtaBand'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
P7Highpackages/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, JoinCtaBandBottomCtaBand 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
P8Lowpackages/ui/src/type-hero.tsx:11-12 vs :51A (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/*

SevFile:lineRuleWhat's thereWhat's requiredFix
Highsession-detail-screen.tsx:358-477DFlatColumn (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 framedApply P1 fix; gate NoticeSection/LiveParticipantBar/RefundBanners/PaymentGateBanner/HostToolsSection/DangerZoneSection with call-site ternaries instead of internal self-gates
Mediumsession-detail-screen.tsx:328AcontentContainerStyle={{ paddingBottom: 48 }} always, even though primaryActionBottomCtaBand (line 482-490) frequently rendersP6Conditional paddingBottom
Mediumsession-detail/sections/host-callout-section.tsx:44-58AReturns a bare <>{conditionalBanner}<SectionBlock>…</SectionBlock></> with zero gap between the pending-applicants Banner and the 호스트 SectionBlock when both renderRule A: block peers = M=12Wrap in <YStack gap="$3">
Lowsession-detail/sections/refund-banners.tsx:41-65AReturns <>{banner1}{banner2}</> with 0 gap when both render (rare edge case: pending refund + host overdue refund simultaneously)Block peers = M=12Add gap="$3" wrapper
Inheritssession-detail/sections/info-section.tsx:50, host-tools-section.tsx:60-141BSectionBlock variant="flat" + FactList/RowListP5
Inheritssession-detail-screen.tsx:358ADefault FlatColumnP2/P3

directory-venue-detail-screen.tsx + sections/* + parts/*

SevFile:lineRuleWhat's thereWhat's requiredFix
Highdirectory-venue-detail-screen.tsx:266A<YStack gap="$2"> hand-wraps MediaHero + ClosedVenueBanner, entirely outside FlatColumn's seam ownershipOne 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
Highdirectory-venue-detail-screen.tsx:260ApaddingBottom: 48 even though BottomCtaBand (line 331) is unconditional on this screenP6, worst instance since the band always rendersChange to paddingBottom: 16 ($4)
Highdirectory-venue-detail-screen.tsx:311-326Dstate.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 bugConvert every section renderer to a call-site ternary, or fix FlatColumn/toArray usage per P1
Meddirectory-venue-detail-screen.tsx:307D, FStatStrip sits as FlatColumn's first item, immediately followed by ActionIconRow — divider renders after itP4

venue-detail-screen.tsx

SevFile:lineRuleWhat's thereWhat's requiredFix
Good pattern:324-367DOptional 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-388ApaddingBottom: 48 always; BottomCtaBand only renders when hasBookingAlertP6Conditional padding
Medium:257-286EactionItems can shrink to 2 (Navigation+Map only) when the venue has no bookingUrl/bookingPhoneActionIconRow 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:309ADefault FlatColumnP2/P3
Inherits:314-359BFactList under SectionBlockP5

match-board-screen.tsx + match-board/sections/*

SevFile:lineRuleWhat's thereWhat's requiredFix
Highmatch-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 ScrollViewFile's own doc comment: "the lifecycle CTA…moved off the scroll body into BottomCtaBand — the one pinned page action" — not true as builtP7 fix
Mediummatch-board-screen.tsx:235,265D<UnsettledPoolNotice/> (unsettled-pool-notice.tsx:28) and <AllDoneBanner/> (all-done-banner.tsx:34) self-gate internally but are unconditional FlatColumn childrenP1Ternary-gate at call site
Inherits:232ADefault FlatColumnP2/P3

record-detail-screen.tsx + record-detail/*

SevFile:lineRuleWhat's thereWhat's requiredFix
Good pattern:110-256DAll 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:217A<YStack gap="$4"> between match-date groups inside the 매치 목록 SectionBlockRule A: block peers = M=12, not L=16Change to gap="$3"
Medium:147-160D, FStatStrip sits between the scope-selector row and the 활동 SectionBlock — framed by dividers on both sidesP4
Good patternmatch-timeline-row.tsx, :236-241DRowList dividers explicitly justified ("MatchTimelineRow is a multi-component block…owner ruling 2026-09-06")Rule D explicitly allows thisCompliant

profile-screen.tsx + achievements-preview-section.tsx + attendance-section.tsx + manner-tags-section.tsx + friends-summary-card.tsx + profile-menu-card.tsx

SevFile:lineRuleWhat's thereWhat's requiredFix
Highprofile-screen.tsx:295-370D, FBare <YStack gap="$1"> ELO fact is FlatColumn's first child, immediately followed by StatStrip(s) — framed by dividers before and afterP4
Mediumprofile-screen.tsx:327F<YStack gap="$2"> between the two stacked StatStripsRule F: "two strips stacked = 12" (M), not S=8Change to gap="$3"
Mediumachievements-preview-section.tsx:121-131Well contract<Well key={achievement.id} width={44} height={44} padding="$0" …> overrides Well's own fixed $3 paddingwell.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
Lowfriends-summary-card.tsx:81, achievements-preview-section.tsx:40-49DFriendsSummaryCardContent/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 patternprofile-menu-card.tsx:44-57ERowList of ListRows, no dividers, "peers…spacing only" commentCompliant

public-profile-screen.tsx

SevFile:lineRuleWhat's thereWhat's requiredFix
High:337-401D, FBare <XStack> ELO fact is FlatColumn's first child, StatStrip immediately followsP4
Medium:405-409 vs :112-114H/DBottomCtaBand 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 absentGate the BottomCtaBand itself on friendship?.status !== 'blocked'
Medium:306ApaddingBottom: 48 always vs conditional BottomCtaBandP6Conditional padding

join-review-detail-screen.tsx

SevFile:lineRuleWhat's thereWhat's requiredFix
High:269-320— (P7)<DetailShell … scroll> (explicit truthy scroll) — BottomCtaBand (line 295) is a plain child, inside the ScrollViewSame as match-board: not actually pinnedP7 fix
Medium:296A<XStack gap="$2"> between the 거절/승인 buttonsRule A: "button-pair gap" = M=12Change to gap="$3"
Inherits:283ADefault FlatColumnP2/P3
Inherits:284-286BFactList under SectionBlockP5

public-club-profile-screen.tsx + public-club-profile/*

SevFile:lineRuleWhat's thereWhat's requiredFix
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 ScrollViewFile's own doc comment: "Application panel (Well) + pinned JoinCtaBand" — not actually pinnedP7 fix
Medium:381B<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 ruleRule 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-534DArchived banner / guest-sessions / description / application-panel are all ternary-gated at the call siteCompliant
Low (unverified)activity-card-section.tsx, gallery-card-section.tsxDAlways rendered as unconditional FlatColumn children (unlike the other four optional blocks); not confirmed whether they self-gate to null on empty dataIf they do, P1 appliesVerify; ternary-gate if so

post-detail-screen.tsx + post-detail/*

SevFile:lineRuleWhat's thereWhat's requiredFix
High (architectural)whole fileA, DThis 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 independentlyRule 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
Mediumpost-detail-screen.tsx:135-140DrenderComment 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:312AcontentContainerStyle={{ paddingTop: 16, paddingBottom: 48 }} — no hero on this page, so 16 stands in for the chrome→content seamRule A puts generic chrome→content at M=12Low confidence this is wrong (no hero to compare against) — flagging for owner call

Findings count per rule letter

RuleDistinct findingsNotes
A (seam ladder)133 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)2FactRow/ListRow first/last padding (P5, cross-cutting — inherited by ~8 pages), public-club-profile's suppressed-lint ad hoc paddingTop
C (two rails)0No violations found — $4 rail and full-bleed media are consistently correct everywhere audited
D (divider law)4 classes / ~9 page instancesP1 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)1venue-detail's ActionIconRow can drop below its 3-item floor
F (info blocks)2StatStrip primitive itself (P4), profile-screen's stacked-strip gap (8 instead of 12)
G (badge placement)0Every MediaHero/TypeHero use audited (session-detail, public-club-profile, profile, public-profile) correctly uses one titleBadge + ≤2 badges
H (affordance)1public-profile's empty BottomCtaBand when FriendshipButton is null (blocked)
I (hero)0132px band / 56px avatar / pageTitle 24/32 consistent everywhere
Structural (P7, un-lettered)3match-board, join-review-detail, public-club-profile all fail to pin their BottomCtaBandDetailShell'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).

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