Skip to content

Flat layout — implementation plan (track F)

Status: Active · Owner ruling (2026-09-07): one grammar keyed on content shape; every detail page, list/settings shell and club surface on the flat canvas; object cards only inside browsing collections; device verification waits until the flat layouts are properly built. Spec: the approved boards on the cockpit review lane (/wireframes), docs/canon/components.md (Canvas, Divider, Seam-ladder, Group-spread, Hero canon, Content-inset laws), docs/design/decision-log-detail-pages.md.

Slices follow the implementation plan contract. Execution follows the slice-completion loop with one substitution the owner made: stage 7's device check is replaced by the web-render screenshot rung (F0-c) until F8.

Why this order

The session card is rendered on ten list sites and inside the club card and club detail; the shells and the flat primitives are read by every page. Anything built on top of a foundation that then moves has to be redone. So: foundation and the card first (F0, F1), then the club detail as the reference page every other page copies (F2), then the mechanical migrations (F3, F4), then the feature slices whose boards are approved (F5–F7), and only then the default flip and the deferred device pass (F8).

Inventory (2026-09-07, from the code)

  • Flat primitives that exist and are contract-tested: FlatColumn, SectionBlock variant="flat", SectionHeader (+link), ListRow, FactRow/FactList, RowList, StatStrip/CellRow, ActionIconRow, Well, Callout, Banner, MediaHero, TypeHero, DetailShell ground, PlaceShell, BottomCtaBand, Divider, PillNav variant="underline", MiniBarMeter, SEAM, CONTENT_INSET/SCROLL_CONTENT, HAIRLINE (packages/ui/src/__tests__/seam-contracts.test.ts).
  • Kit mocks with no real counterpart: WFSessionCardV3/WFSessionStateWord (the no-strip card), WFRankRow (real is LeaderboardRow + ranking cells). Kit API drift: FlSectionTitle right is a string; the real SectionHeader link is { label, onPress }.
  • Tinted shells: 50 files render DetailShell on the default tinted ground once apps/ routes and Codex's venue-contribution/owner-claim screens are counted (scripts/tinted-shells-baseline.json is the authoritative list; the feature-screen core is activity 2, clubs 14, home 1, messaging 4, profile 13, records 3, sessions 1). 20 screens are already on ground="canvas".
  • <Card in features: 134 uses / 88 files — ~6 object cards (stay), ~40 modal/form sections (stay), ~30 files of utility chrome on detail/list pages (flatten).
  • StatGrid: one consumer (member-stats modal, allowed). Inline StatTile clusters: club attendance (4), dues summary (3), pickup host card (2) — all become StatStrip.
  • Session strip consumers: session-strip-ui.tsx, session-compact-row.tsx, session-card-v2.tsx, club-card.tsx, club-status-banner.tsx, session-detail-screen.tsx, closed-banner.tsx, use-session-detail-model.ts, home-today-view.tsx, live-session-stack.tsx, domain session-strip.ts + 3 tests.

Slices

F0 — Foundation: contracts, parity, verification rung · Accepted 2026-09-07 (kit parity test, tinted-shell ratchet at 50, yarn screenshot-web rendering 8/8 default routes)

FieldValue
Primary artifactA foundation that later slices can build on without moving: kit ↔ code parity, a tinted-shell ratchet, and a web-render screenshot rung
Allowed surfacespackages/ui/src, docs/twomore_design_system/wireframes/_kit.jsx, scripts/, package.json scripts, docs/canon/components.md
Blocked scopeNo screen migrations; no session-card change (F1)
Start conditionThis plan committed
Acceptance proof(a) _kit.jsx FlSectionTitle takes link={{label}} like the real SectionHeader; WFRankRow documented as LeaderboardRow + cells; every Fl* metric matches seam-contracts.test.ts (a parity test reads both). (b) scripts/check-tinted-shells.mjs + baseline (37) wired into yarn check: a new tinted shell fails, a migrated one lowers the baseline. (c) scripts/screenshot-web.mjs: boots apps/web against the local stack seeded by yarn qa:seed --local, logs in as the dev account, screenshots a route list at 390×844 with the local Chromium, writes PNGs to the scratchpad; documented in docs/workflows/qa-automation.md as rung 2b.
Verificationyarn check; one screenshot run over five current flat pages (session detail, public club profile, post detail, record detail, venue detail) that renders without error
Stop conditionIf the web target cannot render a feature screen (native-only module), record the route as native-only in the script and move on; do not port modules
Deferral ruleFindings → this file, "Deferred"
Handoff outputThe three artifacts above + a rebuild-log entry

F1 — Session card v3: retire the top strip · Accepted 2026-09-07 (SessionStateInline on the closing row, bookmark on the venue line, SessionStripChipBadge in the detail hero, terminal Banner; rung-verified on home, activity, session and club detail)

FieldValue
Primary artifactSessionCardV2 without the band, per /wireframes/session-card: venue line = venue · weather · bookmark; closing row = state word + tone (pulse when live) · standing badges (호스트 · 참석/예비) · meter
Allowed surfacespackages/app/src/domain/session-strip.ts (becomes the state/standing model), session-strip-ui.tsx, session-card-v2.tsx, session-compact-row.tsx, club-card.tsx (row dot tone), session-detail-screen.tsx + use-session-detail-model.ts (detail band → hero badge row), home-today-view.tsx, live-session-stack.tsx, the 3 domain/feature tests, i18n keys, the strip-map board (→ decided/superseded)
Blocked scopeTerminal banners (club-status-banner, closed-banner) stay banners; no card-list site changes beyond the card itself
Start conditionF0 accepted; the owner has seen the card board (2026-09-07) — the bookmark placement is the owner's; confirm nothing else is open before the first commit
Acceptance proofCard renders with no band on all ten list sites; a saved card shows the bookmark right of the weather; live/closing/open/waitlist render the state word with tone; SessionStripView size="detail" is gone and the detail hero's badge row carries the state; strip-map board archived; screenshot rung shows home, activity, club schedule, session detail
Verificationyarn check; updated session-strip.test.ts / session-info-model.test.ts / session-detail-screen.test.tsx; screenshot rung
Stop conditionIf the detail screen needs a new primitive for terminal states beyond Banner, stop and design it on the board first
FieldValue
Primary artifact/wireframes/club-detail-redesign built: home v2.2 (next-session card v3, vertical 이후 일정 rows, two news rows, one match row, 내 조/내 순위 fact rows, gallery grid), 일정 as compact rows grouped 예정/완료 (no card boxes inside the detail page), 채팅 rows as-is, 랭킹 as-is, 멤버 role sections divided by the column with spacing-only rows, 회비 with a bare month row + StatStrip lead + Well bank info + flat dues rows; 소식→게시판, 기록→랭킹 renames (i18n only)
Allowed surfacespackages/features/clubs/src/club-detail/*, club-dues-screen.tsx (+ dues/*), club-members/member-row.tsx (hairline → spacing-only under RowList), i18n ko/en/clubs.ts, tests
Blocked scopeOption-3 IA (게시판 as a tab, 관리 into overflow) stays held; ops screens are F3
Start conditionF1 accepted (the home tab embeds the card)
Acceptance proofEach tab matches its board phone in the screenshot rung; club-members-screen.test, club-dues-screen.test updated; no <Card under club-detail/ except the club card itself
Verificationyarn check; screenshot rung on the six tabs with the seeded club

F3 — Club ops: 관리 + six 운영 screens flat · Accepted 2026-09-07 (baseline 50 → 43; rung-verified on the seven web routes)

FieldValue
Primary artifact/wireframes/club-ops built, order 회비 (shared with F2) → 출석 → 멤버 → 관리 → 코트 → 대항전 → 일정
Allowed surfacesclub-admin-screen.tsx + admin-nav-row.tsx (→ ListRow), club-members-screen.tsx + members-list-header.tsx (three Cards → ListRow entries with count badge), club-attendance-screen.tsx (StatStrip lead; row = name + MiniBarMeter + rate, no 4-tile cluster; StrikeRecordRow unboxed), club-venues-screen.tsx (+ a flat variant of venue-row.tsx), club-challenges-screen.tsx (StatStrip lead + grouped rows), club-sessions-screen.tsx (card v3 only), all on ground="canvas"
Blocked scopeAnalytics/settings screens (F4)
Start conditionF2 accepted
Acceptance proofSeven screens match their phones; tinted-shell baseline drops by 7

F4 — Canvas migration of the remaining shells · Accepted 2026-09-07 (a9c0a84f · deb191e2 · round three; baseline 35 → 0)

Round one (settings cluster 8 + profile forms 5 + records 3 + home 1 + courts 1 + payments + content-page): SettingsRow is a ListRow wrapper and SectionCard is retired; forms sit on FlatColumn with Banner errors and BottomCtaBand CTAs; head-to-head leads with a StatStrip. Round two (messaging 4, activity 2, clubs misc 7, share routes 2, profile 2): every remaining shell on the canvas; Divider seam="section"/bleed replaces the four hand-rolled section seams; lint @twomore/no-hand-hairline. Round three: the eight club-settings section files unboxed (rows as ListRow/RowList, summaries as Well/FactList, add tiles as AddCard). Left for later slices: EditFieldRow's own row divider (a shared primitive whose change ripples), the ClubCard (F5), the web rung's short-page ground artifact (native fills the screen), and a pre-existing flash: the club-settings 정기 일정/조 관리/게스트/모집 계획 sections render their empty Well while their queries are still loading (no loading gate is passed down) — the rung caught it as an "empty" capture of a seeded club.

FieldValue
Primary artifactEvery DetailShell on ground="canvas" with its utility chrome flattened, in risk order per /wireframes/canvas-migration (decided): batches of ≤7 files — (1) profile/settings 13, (2) records 3 + home 1 + courts 1, (3) messaging 4 + activity 2, (4) clubs misc (admin, album, analytics, settings, discover, join, post-create, partner-suggester)
Allowed surfacesThe listed screens + their class-(c) utility cards (summary/entry/hero cards → TypeHero/StatStrip/ListRow/Well); tests that mock Card
Blocked scopeModal/form-section cards stay; object cards stay
Start conditionF3 accepted
Acceptance proofTinted-shell baseline reaches 0; each batch screenshot-verified; group-spread law holds (no space-between peer rows introduced — lint @twomore/no-raw-content-inset + a grep gate in the batch review)

F5 — Club cards and the public profile · Card shipped 2026-09-07 (D23-E); guest hub pending

Owner rulings 2026-09-07: D23-E is the card — recruitment status top-right, capacity chip on the seam, terms row (join policy, dues) below the identity row. Shipped in club-card.tsx / club-photo-seam.tsx / club-card-model.ts with kit parity. The PublicClubProfileScreen guest hub half of this slice is still planned.

FieldValue
Primary artifactDiscover ClubCard: recruitment status (N명 모집 중) top-right, capacity chip stays on the seam, terms chips as the bottom row (D23-E); PublicClubProfileScreen guest hub: proof strip, locked 게시판/랭킹/멤버 rows, gallery before description, CTA sublabel
Allowed surfacesclub-card.tsx, club-photo-seam.tsx, club-card-model.ts (proof signals already computed), public-club-profile/*, join-cta-band.tsx (sublabel slot on BottomCtaBand)
Start conditionF1 accepted; the owner confirms the terms-row placement (open note on the clubs board)

F6 — Guest → member review flow · Planned

FieldValue
Primary artifact/wireframes/guest-roster built: 심사함 club-wide inbox (read model over club_join_request + per-session guest_applications + club_guests.proposed), the guest profile page (identity hero, participation StatStrip, qualification FactRow checklist, visit-history rows), flat roster (club-guests-section), candidate signals for nominate_for_membership and membership_recommendations, guest fee on the session_payments hold row
Allowed surfacesOne migration (inbox RPC + two signals_emit calls + fee hold seed), join-review-detail-screen.tsx, club-members/*, settings/club-guests-section.tsx, members/nominate-guest-modal.tsx, hooks/adapters via @/registry, pgTAP
Start conditionF3 accepted; the owner confirms the guest-fee settlement authority (session host or canManageDues)

F7 — Venue finder streamlining · Shipped 2026-09-08 (batches A–C + 클럽 코트 pick-intent)

Owner rulings 2026-09-07: yes to the ship-now set; hours standardized (one parser over the three production shapes) with client-side open-now; recent searches and 최근 본 코트 per device; near-me default only when permission is already granted, never a prompt on open; the trust badge only for owner-verified venues; 클럽 코트 pick-intent explained, decision still open. Shipped: list projection widened (rating, review count, hours, telephone, fee schedule), parseOperatingHours/isVenueOpenNow/isVenueFree in domain/venue, 무료 and 영업 중 as client-side facets, verification field removed from the finder, card v3 (rating + review count, open-now word, shop card 영업시간·전화), near-me default from useUserLocation. Data finding: only 28 of 645 full courts carry hours and 304 carry a phone on production; 234 phone-less courts have a Naver place id — a refresh, not a UI fix.

The research pass (board vs code) found that rating/reviewCount/operatingHours only need VENUE_LIST_COLUMNS widened (the entity and mapper already carry them; no migration), but six things the board treats as settled are not: (1) 최근 검색/최근 본 코트 have no persistence anywhere — a new MMKV-backed store (shape, cap, dedup) is a design decision; (2) "near-me default when granted" has no defined gating (today 가까운 순 is an opt-in toggle that prompts for permission); (3) 지금 영업 중 needs a real open-now predicate — the only existing hours predicate is open_24h's regex and operating_hours JSONB is documented as not standardized — server-side predicate or client parse; (4) pick-intent 클럽 코트 needs club context threaded from the wizard steps, which are outside the allowed surfaces; (5) the shop card's 전화 needs telephone in the list projection, which the board's data note omits; (6) the board's card mock shows 미확인, which canon/venue-data.md forbids — the real card keeps VenueTrustBadge's certification path. Recommended path: ship (a) projection widen + card v3 rating/open-now (client-side open-now from a standardized hours parser with a null fallback that hides the dot) + 무료 facet + verification field removal + telephone in the shop card now; hold (b) recent searches, near-me default and 클럽 코트 until the owner rules on store shape, gating and the club-context prop path.

FieldValue
Primary artifact/wireframes/venue-finder built: near-me default when location is granted, zero-query state (저장 → 최근 → 가까운; pick: 클럽 코트 → 최근 선택 → search), recent searches under the field, 영업 중 · 무료 facets, verification toggle type removed, card v3 (rating + open-now), shop card 영업시간·전화
Allowed surfacesvenue-finder/*, venue-filter-bar.tsx, directory-venue-card.tsx, venue-search-takeover.tsx, use-venue-directory.ts + the list projection (rating/reviewCount/open-now into the list SELECT), one migration if the RPC changes, i18n venues.ts, tests
Start conditionF1 accepted (no dependency on F2–F6); the Codex venue branch is merged (done 2026-09-07)

F8 — Default flip and the deferred device pass · Code shipped 2026-09-07 — device pass open

DetailShell is canvas-only: the ground prop is gone (swept from 67 screens), resolveDetailShellContentDefaults takes no ground, scripts/check-tinted-shells.mjs and its baseline are deleted, shell-ground.test.ts covers the canvas defaults. MainTabShell keeps the tinted option: three of its four consumers (the main tabs other than 프로필) still ride the legacy ground — the owner's call, not part of this track's ruling. Boards: only venue-finder is approved (F7 not built), so nothing flips to decided yet. Remaining: the owner's on-device verification of the venue release and the flat layouts together on preview.

FieldValue
Primary artifactDetailShell default ground = canvas, the tinted option and shell-ground.test.ts retired, check-tinted-shells deleted; the approved boards flipped to decided; then the owner's on-device verification of the venue release and the flat layouts together (preview OTA)
Start conditionF4 accepted and F1–F3 on preview

Working rules for every slice

  • Read the board and the canon paragraphs before touching a file; the board is the spec, the code is the truth for what exists.
  • Compose only from the flat primitives; a screen never types a seam value, a raw inset, a space-between peer row, or a hairline of its own.
  • Agents: sonnet, ≤3 parallel, ≤7 files each, never git stash/checkout/reset; Opus reviews every diff against the board before commit.
  • Every slice ends with: yarn check, the screenshot rung, a rebuild-log entry, the board status updated, the tinted-shell baseline lowered.

Deferred

  • (empty)

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