Skip to content

refactor(mobile): centralize safe-area spacing in useScreenInsets#2314

Open
Gilbert09 wants to merge 1 commit into
mainfrom
mobile-screen-insets-hook
Open

refactor(mobile): centralize safe-area spacing in useScreenInsets#2314
Gilbert09 wants to merge 1 commit into
mainfrom
mobile-screen-insets-hook

Conversation

@Gilbert09
Copy link
Copy Markdown
Member

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

  • useScreenInsets hook — single source of truth for spacing policy. Wraps useSafeAreaInsets() and exposes a rationalized 12/24/40 scale via bottom("compact" | "default" | "roomy"), plus composerBottom(), fabBottom(), contentTop(), and raw insets.
  • SheetContainer component — extracts the duplicated bottom-sheet shell (backdrop, pinned panel, rounded top, border, shadow, drag handle, inset-aware padding). SelectSheet and AttachmentSheet now render through it.
  • Migrated 17 call sites — FABs, composers, page sheets, and the mcp/settings/pr-diff/automation-create screens.

Rationalization (intentional)

Two values snap onto the scale; everything else maps exactly:

  • settings bottom: 32 → 24
  • pr-diff bottom: 16 → 24

Left 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: clean
  • tsc --noEmit: no new errors (pre-existing test-file errors unchanged vs. main)
  • ⚠️ Not visually verified on a simulator — the two 8px shifts (settings, pr-diff) are low-risk but worth an eyeball in light/dark.

🤖 Generated with Claude Code

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>
@Gilbert09 Gilbert09 requested review from dmarticus and oliverb123 May 22, 2026 21:07
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 22, 2026

Prompt To Fix All With AI
Fix 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

Comment on lines +62 to 63
const { insets, composerBottom } = useScreenInsets();
const themeColors = useThemeColors();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
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.

Comment on lines 125 to +128
return {
marginBottom: height.value < 0 ? 0 : Math.max(insets.bottom, 50),
marginBottom: height.value < 0 ? 0 : composerBottom(),
};
}, [insets.bottom]);
}, [composerBottom]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Once the value is pre-computed outside the worklet, use the scalar directly and update the dependency array accordingly.

Suggested change
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.

Copy link
Copy Markdown
Contributor

@dmarticus dmarticus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = "",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants