refactor(mobile): centralize safe-area spacing in useScreenInsets#2314
refactor(mobile): centralize safe-area spacing in useScreenInsets#2314Gilbert09 wants to merge 1 commit into
Conversation
Screens previously hand-rolled their bottom/top safe-area spacing, each calling useSafeAreaInsets() and adding its own magic offset (12/16/20/24/32/40/50). The device insets are the right cross-platform primitive, but the policy on top of them was duplicated and inconsistent. Add a useScreenInsets hook that owns that policy with a rationalized 12/24/40 scale (compact/default/roomy) plus composerBottom, fabBottom, and contentTop helpers. Extract the duplicated bottom-sheet shell into a SheetContainer component (backdrop, panel, shadow, drag handle, inset) and move SelectSheet/AttachmentSheet onto it. Migrate FABs, composers, page sheets, and the mcp/settings/pr-diff/ automation-create screens onto the hook. Two values snap to the scale (settings 32->24, pr-diff 16->24); everything else maps exactly. Functional clearances (clearing a sticky footer or floating button, measured header heights) are left bespoke since they aren't safe-area policy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prompt To Fix All With AIFix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
apps/mobile/src/app/task/[id].tsx:62-63
`composerBottom` is a plain JS function from `useMemo`, not a Reanimated worklet. `useAnimatedStyle` callbacks that read a `SharedValue` (`height.value`) are serialized and executed on the UI thread, where calling non-worklet JS functions throws a runtime error. The sibling file `task/index.tsx` avoids this correctly by pre-computing `restingBottom = bottom("compact")` outside the animated style and capturing the primitive number in the worklet closure. The same pattern should be applied here.
```suggestion
const { insets, composerBottom } = useScreenInsets();
const composerBottomValue = composerBottom();
const themeColors = useThemeColors();
```
### Issue 2 of 2
apps/mobile/src/app/task/[id].tsx:125-128
Once the value is pre-computed outside the worklet, use the scalar directly and update the dependency array accordingly.
```suggestion
return {
marginBottom: height.value < 0 ? 0 : composerBottomValue,
};
}, [composerBottomValue]);
```
Reviews (1): Last reviewed commit: "refactor(mobile): centralize safe-area s..." | Re-trigger Greptile |
| const { insets, composerBottom } = useScreenInsets(); | ||
| const themeColors = useThemeColors(); |
There was a problem hiding this comment.
composerBottom is a plain JS function from useMemo, not a Reanimated worklet. useAnimatedStyle callbacks that read a SharedValue (height.value) are serialized and executed on the UI thread, where calling non-worklet JS functions throws a runtime error. The sibling file task/index.tsx avoids this correctly by pre-computing restingBottom = bottom("compact") outside the animated style and capturing the primitive number in the worklet closure. The same pattern should be applied here.
| const { insets, composerBottom } = useScreenInsets(); | |
| const themeColors = useThemeColors(); | |
| const { insets, composerBottom } = useScreenInsets(); | |
| const composerBottomValue = composerBottom(); | |
| const themeColors = useThemeColors(); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/mobile/src/app/task/[id].tsx
Line: 62-63
Comment:
`composerBottom` is a plain JS function from `useMemo`, not a Reanimated worklet. `useAnimatedStyle` callbacks that read a `SharedValue` (`height.value`) are serialized and executed on the UI thread, where calling non-worklet JS functions throws a runtime error. The sibling file `task/index.tsx` avoids this correctly by pre-computing `restingBottom = bottom("compact")` outside the animated style and capturing the primitive number in the worklet closure. The same pattern should be applied here.
```suggestion
const { insets, composerBottom } = useScreenInsets();
const composerBottomValue = composerBottom();
const themeColors = useThemeColors();
```
How can I resolve this? If you propose a fix, please make it concise.| return { | ||
| marginBottom: height.value < 0 ? 0 : Math.max(insets.bottom, 50), | ||
| marginBottom: height.value < 0 ? 0 : composerBottom(), | ||
| }; | ||
| }, [insets.bottom]); | ||
| }, [composerBottom]); |
There was a problem hiding this comment.
Once the value is pre-computed outside the worklet, use the scalar directly and update the dependency array accordingly.
| return { | |
| marginBottom: height.value < 0 ? 0 : Math.max(insets.bottom, 50), | |
| marginBottom: height.value < 0 ? 0 : composerBottom(), | |
| }; | |
| }, [insets.bottom]); | |
| }, [composerBottom]); | |
| return { | |
| marginBottom: height.value < 0 ? 0 : composerBottomValue, | |
| }; | |
| }, [composerBottomValue]); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/mobile/src/app/task/[id].tsx
Line: 125-128
Comment:
Once the value is pre-computed outside the worklet, use the scalar directly and update the dependency array accordingly.
```suggestion
return {
marginBottom: height.value < 0 ? 0 : composerBottomValue,
};
}, [composerBottomValue]);
```
How can I resolve this? If you propose a fix, please make it concise.
dmarticus
left a comment
There was a problem hiding this comment.
Nice cleanup. The hook and SheetContainer both pull their weight — 12/24/40 is the right level of abstraction for the scatter it's replacing, and the sheet shell is genuinely shared rather than over-fit. Approving; comments below are non-blocking nits to look at before/after merge.
One thing worth eyeballing on a simulator given you flagged it: the two declared 8px shifts (settings 32→24, pr-diff 16→24) and the roomy variant collapsing the previous +40 sites — 60 seconds on iPhone SE + Pro Max in both modes should be enough.
| marginBottom: height.value < 0 ? 0 : composerBottom(), | ||
| }; | ||
| }, [insets.bottom]); | ||
| }, [composerBottom]); |
There was a problem hiding this comment.
Subtle: useScreenInsets returns a fresh object each render, and the getter functions are re-created on every insets change. [composerBottom] happens to be correct today, but only because nothing in the hook guarantees referential stability of these methods — a future change to useScreenInsets could silently break this. Two options: revert to [insets.bottom] (which is the actual underlying dependency), or wrap the getters in useCallback inside useScreenInsets so this pattern is safe everywhere.
| bottom: (variant: BottomGapVariant = "default") => | ||
| insets.bottom + BOTTOM_GAP[variant], | ||
| /** Top padding for page-sheet content (inset + standard top gap). */ | ||
| contentTop: () => insets.top + TOP_GAP, |
There was a problem hiding this comment.
contentTop() is asymmetric with bottom(variant) — it hides TOP_GAP = 8 as a private constant with a single zero-arg getter, while bottom exposes a scale. Several screens still hand-roll insets.top + 60 (mcp add-custom, installation/[id], template/[id]) and insets.top + 56 (task/index). If those are intentional one-offs that's fine, but consider either renaming this to sheetContentTop() to signal "this is only the +8 sheet-content case," or accepting a variant like bottom() does. As written, the name reads broader than the implementation.
| /** Bottom offset for a floating action button. */ | ||
| fabBottom: () => insets.bottom + FAB_GAP, | ||
| /** Composer bottom margin floor (never smaller than the min). */ | ||
| composerBottom: () => Math.max(insets.bottom, COMPOSER_MIN_BOTTOM), |
There was a problem hiding this comment.
Worth a one-line JSDoc on fabBottom() and composerBottom() clarifying these are domain-specific, frozen choices — not variants of BOTTOM_GAP. Sitting next to bottom(variant) they read like siblings, and a future caller will reasonably try fabBottom("roomy").
| open, | ||
| onClose, | ||
| children, | ||
| bottomGap = "compact", |
There was a problem hiding this comment.
Default bottomGap = "compact" matches the legacy sheet shell (both SelectSheet and AttachmentSheet previously used +12), but the name footgun is real: a future caller will reasonably expect the default to be BOTTOM_GAP.default (24). A brief comment here noting "compact is the inherited sheet default" would prevent that confusion.
| onClose, | ||
| children, | ||
| bottomGap = "compact", | ||
| className = "", |
There was a problem hiding this comment.
CLAUDE.md mobile rules say custom wrappers should accept both className and style so call sites can override without forcing inline style elsewhere. Cheap to add now; otherwise a future need for maxHeight or a measured value forces a fork.
| composerBottom: () => Math.max(insets.bottom, COMPOSER_MIN_BOTTOM), | ||
| }; | ||
| }, [insets]); | ||
| } |
There was a problem hiding this comment.
No test for the hook. Now that this is the single source of truth for spacing, a 6-line vitest pinning the scale (bottom("compact") === insets.bottom + 12, etc.) would catch any future "bump compact to 14" accident cheaply.
Why
On mobile, screens each called
useSafeAreaInsets()and then hand-added their own magic offset for top/bottom spacing (12 / 16 / 20 / 24 / 32 / 40 / 50). The device insets are the correct cross-platform/cross-device primitive — and they're used — but the policy layered on top was duplicated and inconsistent per screen. (Originated from a question about the gap under the task-screen composer:Math.max(insets.bottom, 50), correct for that screen but not shared anywhere.)What
useScreenInsetshook — single source of truth for spacing policy. WrapsuseSafeAreaInsets()and exposes a rationalized 12/24/40 scale viabottom("compact" | "default" | "roomy"), pluscomposerBottom(),fabBottom(),contentTop(), and rawinsets.SheetContainercomponent — extracts the duplicated bottom-sheet shell (backdrop, pinned panel, rounded top, border, shadow, drag handle, inset-aware padding).SelectSheetandAttachmentSheetnow render through it.Rationalization (intentional)
Two values snap onto the scale; everything else maps exactly:
settingsbottom: 32 → 24pr-diffbottom: 16 → 24Left bespoke on purpose
Functional clearances that aren't safe-area policy: DismissReportSheet's
+120(clears its sticky footer), automation/create's computed floating-button clearance, inbox/[id]'s+100/+16, and measured floating-header heights (insets.top + 60/64).Testing
biome check: cleantsc --noEmit: no new errors (pre-existing test-file errors unchanged vs.main)🤖 Generated with Claude Code