Skip to content

Flat spacing audit — 2026-09-08

Status: Active · Trigger: owner, 2026-09-08 — "many of the flat pages we've implemented don't seem to have proper spacing and alignment." · Method: local-stack seed (club_showcase) + web-render screenshot rung (scripts/screenshot-web.mjs) over every apps/web route named in the ask — home/records/profile/courts/activity tabs, session detail, the club home + all 5 member tabs (홈·일정·채팅·순위·회비), all 10 club 운영 screens (admin/members/attendance/dues/sessions/venues/challenges/settings/analytics/partners), discover, settings ×3, profile/friends, records history/leaderboard, directory-venue detail, messages, public profile — plus a full re-read of the flat primitives (FlatColumn, Divider, CellRow, StatStrip, FactRow, ListRow, RowList, SectionHeader) against docs/canon/components.md's Canvas/Divider/Seam-ladder/Group-spread/Hero laws.

Capture caveats (read before the findings)

  • This machine runs several concurrent Claude/Codex sessions against the same local Supabase stack. The local DB was reset out-of-band by another session twice during this audit, invalidating two full screenshot passes (stale ids resolving to "클럽을 찾을 수 없어요"). Every screenshot cited below was re-captured after the final reset, against club d304c404-ed2e-4509-9d91-2d26320169dd ("강남 코트 클럽") — verified live in the DB at time of writing.
  • Headless Chromium in this sandbox does not load the lucide icon font/SVG sprite for a handful of icon call sites, so several screenshots show a "☒" tofu glyph where a real device renders a MapPin/Users/etc. icon (e.g. the region chip on club home, fact-glyphs on /courts). This is a capture-environment artifact, not a code defect — verified by grep: those call sites use the canonical Icon/FactGlyph components, not a broken import. Not counted as a finding.
  • /, /records (tab-root) and /sessions/<id> repeatedly screenshotted mid-query (skeleton state) despite the script's hydration wait — likely dev-mode query latency under the shared box's memory pressure, not a rendering bug. Not counted as a finding; re-verify these three on a quieter box before trusting a "clean" read.

Verdict

The flat-layout primitives (FlatColumn, Divider, CellRow/StatStrip, FactRow, ListRow, RowList, SectionHeader) are correctly built and contract-tested (packages/ui/src/__tests__/seam-contracts.test.ts locks every seam to its SEAM.* rung) — reading every primitive source file found no drift from the seam ladder, the divider law, or META_TEXT. Of the ~26 screens read, the great majority (club home, all 5 member tabs, 9 of 10 club-ops screens, courts, activity, dues, partners) render cleanly on-system: full-bleed section dividers at SEAM.section, spacing-only peer rows, chevron affordances present, trailing values right-aligned and vertically centered.

The one confirmed, repeated, code-verifiable defect is a StatStrip/CellRow composition bug: when a screen needs more stat cells than fit one row, two independent call sites hand-stacked two separate CellRow-based rows (3 cells then 2 cells) instead of sharing one grid. CellRow divides its own row's width into equal flex:1 columns — so a 3-cell row and a 2-cell row compute two different column widths for the same page width, and row 2's values drift out from under row 1's. This is exactly the "StatStrip cells uneven" symptom, confirmed pixel-for-pixel in two independent screenshots (own-profile hero, club admin snapshot) before the fix.

Ranked slip table

#Page(s)SlipLaw brokenEvidencefile:lineFix
1/profile (own profile hero) AND /clubs/<id>/admin (attention snapshot)Two stacked stat-cell rows (3-cell then 2-cell) each compute their own column width, so row 2's values don't sit under row 1's — a jagged, misaligned grid instead of one tableGroup-spread law ("equal flex:1 columns... GAP is the constant") applied per-row instead of across the stacked group; the "StatStrip cells uneven" symptom named in the audit briefprofile.png: row 1 cell centers at ~x=80/230/370 (of 390pt viewport), row 2 at ~x=115/330 — visibly off-grid. clubs_..._admin.png: row 1 centers ~x=146/389/635 (of 780px@2x), row 2 ~x=192/559 — same driftpackages/features/profile/src/profile-screen.tsx:334-364 (before fix); packages/features/clubs/src/club-detail/admin-snapshot-strip.tsx:33-49 (before fix)FIXED — added CellRowGrid primitive (packages/ui/src/cell-row.tsx) + chunkCellRowItems (packages/ui/src/cell-row-visual.ts) that pads every stacked row to the SAME column count; StatStrip gained an optional columns prop that routes through it. Both call sites now render one shared grid.
2/clubs/<id>/analytics ("탐색" section)The funnel-step list (SignalRow: 탐색 노출/일정 관심/탐색 피드/club_card/…) stacks label-then-value on two lines, while the stat cells one section above it (StatTile) render value-then-label — two different label/value orders on the same page reads inconsistent, though each is individually a legitimate distinct component (list rows vs. stat cells)Not a named canon law violation — a content-shape difference, not a seam/divider driftclubs_..._analytics.png, "탐색" sectionpackages/features/clubs/src/club-analytics-screen.tsx (SignalRow usage)Not fixed — low confidence this is wrong by design; flagging for owner call before touching SignalRow's shared shape (it's also used by home's notification rows).
3/clubs/<id> home heroClub identity block shows two stacked badge-like rows (region chip with its own chevron, then NTRP badge) rather than one shared badge row under the titlePossible Hero canon drift ("a hero has ONE badge home: the badge row under the title... two badges max in a hero") — but the region row is a navigational multi-region entry (its own chevron), which the Hero canon doesn't explicitly coverclubs_d304c404..._admin.png (home crop, same hero renders on every tab)packages/features/clubs/src/club-detail/* hero composition (not traced to an exact file:line — ran out of audit budget)Not fixed — needs a code trace to confirm before treating as a violation vs. an intentional navigational exception.

Only three rows are listed — every other page read (club schedule/members/dues/chat/records tabs, club attendance/dues/sessions/venues/challenges/settings/partners ops screens, /courts, /activity, /clubs/discover, /settings, /settings/edit-profile, /settings/privacy, /profile/friends, /records/history, /records/leaderboard, /messages, /directory-venues/<id>) showed no seam-ladder, divider-law, or group-spread violations on this pass. That is a genuine finding in itself: the flat migration's foundational work (F0-F2) is holding up under a real cross-app read, and the remaining risk is narrow, repeatable composition bugs like #1 rather than widespread drift.

What was fixed (batch 1 of 1 — no second batch needed)

Root cause shared by both confirmed instances: a stacked multi-row stat/cell group must share one column grid across rows, never let each row size itself independently.

  • packages/ui/src/cell-row-visual.ts — added chunkCellRowItems(items, columns), a pure function that splits a flat cell list into columns-wide rows, padding the final row with null placeholders so every row has the same column count.
  • packages/ui/src/cell-row.tsx — added CellRowGrid (cells + columns + dividers), which renders chunkCellRowItems's rows as stacked CellRows at SEAM.item gap. Exported from packages/ui/src/index.ts.
  • packages/ui/src/stat-strip.tsxStatStrip gained an optional columns prop; when columns < cells.length it routes through CellRowGrid instead of a single CellRow. Every existing single-row call site is unaffected (prop omitted → unchanged behavior).
  • packages/features/profile/src/profile-screen.tsx — the hasAttendance branch's two stacked <StatStrip> calls (3-cell + 2-cell) collapsed into one <StatStrip columns={3} cells={[...5 cells]} />.
  • packages/features/clubs/src/club-detail/admin-snapshot-strip.tsx — replaced the hand-rolled for loop + stacked <CellRow>s with <CellRowGrid columns={CELLS_PER_ROW} cells={cells.map(toCellRowCell)} />.

This is a primitive-level fix: any future screen that needs an N-cell stat/cell group where N doesn't divide evenly gets correct alignment for free via StatStrip columns={n} or CellRowGrid directly, instead of re-deriving the hand-stacking bug.

Re-screenshot verification

Re-captured /profile and /clubs/<id>/admin after the fix (see scripts/screenshot-web.mjs); both now render StatStrip/CellRowGrid cells with row 2 sharing row 1's exact column x-positions (visually confirmed against the pre-fix screenshots archived in the scratchpad web-shots/ directory of this session).

What remains for a next batch

  • Row #2 (analytics SignalRow label/value order) and #3 (club-home hero badge-row count) need a code trace + owner call before any fix — left open rather than guessed at.
  • /, /records, /sessions/<id> need a clean re-capture off this shared, memory-pressured box to actually audit (every attempt here caught them mid-query).
  • This pass did not reach the wizard flows, session-detail sub-routes (attendance/edit/host/match-rules/participants/payments/scorecard), or the messaging thread view — out of the originally-scoped route list but worth a follow-up sweep if the owner wants full coverage.

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