Skip to content

Spacing / rhythm / layout disharmony audit — 2026-09-10

Status: Active · Owner ask: "are there any components or styles or behaviors that are not in sync or harmony?" — spacing/rhythm/layout dimension. The PlaceShell double-inset finding was fixed on 2026-09-10; the rest are open.

Scope: composition-level disharmony (two individually-correct components producing a wrong result together), per the owner's brief and the BottomCtaBand/DetailShell double-inset fix as the model finding. Read: docs/canon/styling.md, docs/canon/components.md, packages/ui/src/seams.ts, packages/ui/src/__tests__/seam-contracts.test.ts, docs/design/flat-spacing-audit-2026-09-08.md (and its cited rhythm/seam audits), packages/eslint-plugin/rules/no-double-rail.js.

Baseline note: the system is unusually well-instrumented for this class of bug — seam-contracts.test.ts locks every primitive's seam to the SEAM ladder, no-double-rail/no-raw-content-inset/one-flat-column catch the mechanical cases, and the 2026-09-08 flat-spacing audit already swept ~26 screens and found only 1 confirmed bug (now fixed). The findings below are the gaps that survive that coverage — confirmed by reading the actual composed values, not inferred.

Findings, ranked

1. PlaceShell reproduces the exact double-bottom-inset bug DetailShell just fixed — club home's two CTA bands (HIGH, visible on the most-trafficked screen in the app)

Files: packages/ui/src/place-shell.tsx:63 (mounts SafeAreaView edges={['bottom']}, no BottomInsetReservationProvider) vs. packages/ui/src/detail-shell.tsx:185 (mounts the equivalent SafeAreaView with edges always including 'bottom', AND wraps children in <BottomInsetReservationProvider reserved>). Consumer: packages/features/clubs/src/club-detail-screen.tsx:521-544 (<BottomCtaBand> for 글쓰기 on the home tab and 일정 만들기 on the schedule tab, mounted directly under PlaceShell, not DetailShell).

Mechanism: BottomCtaBand (packages/ui/src/bottom-cta-band.tsx:73-78) calls useBottomInsetReserved(), which defaults to false unless an ancestor explicitly states reserved. Only DetailShell provides that context (detail-shell.tsx:185). PlaceShell reserves the same edge — edges={['bottom']} — but was never updated to provide the context when the fix landed, so PlaceShell is currently in the exact "before" state the fix's own doc comment describes (bottom-inset-reservation.tsx:18-24): "BottomCtaBand is mounted by BOTH [shells]... this resolved the conflict by always adding insets.bottom itself, which is right under the tab shell and wrong under the detail shell, where the inset then lands twice." PlaceShell behaves exactly like the pre-fix DetailShell for this purpose — it just wasn't in scope of the fix's grep.

Actual resolved values: resolveBandPaddingBottom(base, inset, reserved) returns base + (reserved ? 0 : inset) (bottom-cta-band-visual.ts:45-51). Under PlaceShell, reserved is false, so the band's bottom padding = 16 (SEAM.band) + insets.bottom. But PlaceShell's own SafeAreaView edges={['bottom']} has ALSO already consumed insets.bottom as native inset padding on its container. Total dead space beneath the CTA button = insets.bottom (PlaceShell) + 16 (band base) + insets.bottom (band's own add) = 2×insets.bottom + 16. On an iPhone with a home indicator (insets.bottom ≈ 34pt) that's 34 + 16 + 34 = 84pt of clearance instead of the intended 34 + 16 = 50pt — an extra 34pt of dead space, the same magnitude (24–48px) the fix's own comment names for the bug it closed elsewhere.

Which is correct per canon: DetailShell's pattern (shell states reserved, band reads it) is the fix; PlaceShell is the un-migrated instance.

User-visible symptom: on club home (홈 tab, when the viewer is a member) and club 일정 (schedule tab, when the viewer can manage schedule), the pinned 글쓰기 / 일정 만들기 button reads as floating in an oversized band with a large gap beneath it on any gesture-nav device — the identical symptom the user just fixed on DetailShell, now confirmed still live one shell over.

Fix: wrap PlaceShell's children in <BottomInsetReservationProvider reserved> the same way DetailShell does (place-shell.tsx, mirroring detail-shell.tsx:185/247).


2. no-double-rail lint doesn't cover loadingState, and one real screen already hit the gap (MEDIUM-HIGH — visible every time the screen loads)

Files: packages/eslint-plugin/rules/no-double-rail.js:51 (SLOT_ATTR_NAMES = new Set(['header', 'emptyState', 'footer']) — omits loadingState) vs. packages/ui/src/feed-list.tsx:237-242 (at render time, loading ? loadingState : emptyState feeds the exact same emptySlot<ListSlot>{emptySlot}</ListSlot>ListEmptyComponent path that emptyState uses — ListSlot is a bare, unpadded YStack, packages/ui/src/feed-list/parts.tsx:19-21). Live instance: packages/features/profile/src/consent-history-screen.tsx:111-141.

Mechanism: loadingState and emptyState are the same slot at render time (whichever one shows depends only on loading), so anything that is a rail-doubling bug for emptyState is equally a bug for loadingState — but the lint rule that was written specifically to catch this class (triggered by three real offenders, per its own doc comment) only checks the header/emptyState/footer attribute names.

Actual resolved values, consent-history-screen.tsx: contentContainerStyle={{ ...SCROLL_CONTENT.railedList, paddingTop: CONTENT_INSET.rail }} (line 140) — railedList pads the WHOLE list content 16px horizontally (content-insets.ts:62). loadingState (line 119-125) is <YStack paddingHorizontal="$4" gap="$3"> — another 16px on top. Resolved: the three SkeletonRows during load sit at 32px inset; once loaded, emptyState (line 126-135, correctly no paddingHorizontal) and every real HistoryRow sit at the intended 16px. The skeleton visibly shifts inward relative to the content it's standing in for.

Which is correct per canon: the emptyState sibling three lines down in the same file — no paddingHorizontal — is the canonical shape; loadingState should match it.

User-visible symptom: on consent-history-screen.tsx, every load shows skeleton rows indented 16px further than the real rows/empty text that replace them — a visible jump on data arrival.

Fix: add loadingState to SLOT_ATTR_NAMES in no-double-rail.js (mechanical, same detection logic already handles the shape), and drop the paddingHorizontal="$4" from consent-history-screen.tsx:120. Checked the ~20 other loadingState-with-paddingHorizontal sites app-wide (partner-suggester-screen.tsx, club-venues-screen.tsx, club-sessions-screen.tsx, post-detail-screen.tsx, club-attendance-screen.tsx, ranking-tab.tsx, sessions-pane.tsx, home-tab.tsx, records-history-screen.tsx, club-list-screen.tsx, chat-pane.tsx, activity-screen.tsx, notification-center-screen.tsx, venue-finder-browse-list.tsx) — all of them use SCROLL_CONTENT.list/.banded (rows own their own rail), so their loadingState padding is correct and should NOT be touched. consent-history-screen.tsx is the one outlier because it's the one screen combining a loadingState with a railedList/railedBanded container.


3. dm-thread-screen's keyboard offset hardcodes the 1:1 header height, silently wrong for every group thread (LOW-MEDIUM, iOS only, 8px)

Files: packages/features/messaging/src/dm-thread-screen.tsx:232-233 (keyboardVerticalOffset={Platform.OS === 'ios' ? insets.top + 56 : 0}) vs. packages/ui/src/app-header.tsx:85 (height={subtitle ? 64 : 56}) vs. dm-thread-screen.tsx:130-132 (headerSubtitle = data.isGroup ? strings.memberCount(...) : undefined — every group thread passes a non-empty subtitle to DetailShell/AppHeader).

Mechanism: the screen computes its own KeyboardAvoidingView offset as a literal 56, duplicating (not importing) AppHeader's own height logic instead of reading a shared constant. For a 1:1 thread (headerSubtitle === undefined) the header really is 56px, so the literal happens to match. For a GROUP thread, headerSubtitle is always set (member count), so AppHeader renders at 64px — the literal is off by 8px for every group DM, which is not a rare path (every group chat, always).

Which is correct per canon: neither file states a shared constant — this is a values-bypass gap the styling canon's magic-number rules don't reach (a computed prop value in a feature screen, not a spacing/margin prop the no-numeric-spacing-in-features lint scans).

User-visible symptom: on iOS, opening the keyboard in a group DM thread lifts the composer/list 8px short of where the header actually ends — a small but real misalignment, specific to group threads only (so it also reads as a 1:1-vs-group inconsistency, not just an absolute error).

Fix: derive the offset from the same source AppHeader uses (export the 56/64 header-height constants, e.g. from app-header.tsx or a shared box-sizes.ts entry) instead of re-typing 56.


Checked and clean (worth stating, per the audit's own instruction not to pad with non-findings)

  • FlatColumn rail ownership — audited all ~38 <FlatColumn> consumers for a wrapper paddingHorizontal stacking on top of FlatColumn's own SEAM.rail padding. Every case where a sibling element outside FlatColumn (e.g. public-club-profile-screen.tsx's member-faces row, tournament-board-screen.tsx's host-tools row) carries its own paddingHorizontal={SEAM.rail}, that element is a documented hero-zone sibling BEFORE FlatColumn, not a wrapper around it — single ownership holds. No live double-rail found here; @twomore/one-flat-column plus DetailShell's zero-padding default (content-insets.ts:16-21) is doing its job.
  • WizardShell's footer bottom clearance — does NOT manually add insets.bottom (unlike BottomCtaBand); it relies purely on its own SafeAreaView edges={['top','bottom']}'s automatic reservation, so there's no analogous double-count risk in the wizard shell.
  • Toast-vs-BottomCtaBand z-order on tab screenstoast-host.tsx's own doc comment already flags this as a known, unfixed gap ("BottomCtaBand (club-list/activity tab screens) is NOT wired into the [bottom-chrome height] store... the toast currently floats above the tab bar only and may visually sit close to (or behind) the band"). Not re-reported as a new finding since it's already tracked in-code, but flagging it here since it is a real spacing/z-order disharmony the owner may not have surfaced as a task: packages/app/src/presentation/components/toast-host.tsx vs. packages/features/clubs/src/club-list-screen.tsx / packages/features/activity/src/activity-screen.tsx.
  • Session-attendance / session-payments FeedList composition (the two other no-double-rail historical offenders) — re-checked their current header/footer slots: both are clean (no paddingHorizontal on the slot), and neither has a loadingState prop, so finding #2's gap doesn't recur there today.

Not independently re-verified

  • Wizard step interiors (create-session/create-club per-step rail composition) and the remaining session-detail sub-routes (host tools, match-rules editor body, participants) — the 2026-09-08 audit explicitly left these unread; a spot-check of session-attendance-screen.tsx/owner-frame.tsx found them clean, but a full sweep of every wizard step was outside this pass's budget.

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