diff --git a/apps/readest-app/AGENTS.md b/apps/readest-app/AGENTS.md
index 4183e048..e1cf58fd 100644
--- a/apps/readest-app/AGENTS.md
+++ b/apps/readest-app/AGENTS.md
@@ -66,7 +66,6 @@ Platform-specific code lives in `src-tauri/src/{macos,windows,android,ios}/`. Cu
- No lookbehind regex in build output — verified by `check:lookbehind-regex`.
- Run `pnpm build-check` (builds both targets + runs all checks) before submitting.
-### Formatting
+### Safe Area Insets
-- Prettier is enforced via husky pre-commit hook + lint-staged.
-- Run `pnpm format` to auto-fix.
+See [docs/safe-area-insets.md](docs/safe-area-insets.md) for rules on handling top/bottom insets for UI elements near screen edges.
diff --git a/apps/readest-app/docs/safe-area-insets.md b/apps/readest-app/docs/safe-area-insets.md
new file mode 100644
index 00000000..c9cade17
--- /dev/null
+++ b/apps/readest-app/docs/safe-area-insets.md
@@ -0,0 +1,52 @@
+## Safe Area Insets
+
+The app runs on devices with notches, status bars, and rounded corners (iOS, Android). UI elements near screen edges must account for safe area insets to avoid being obscured.
+
+### Key Concepts
+
+- **`gridInsets: Insets`** — Per-view insets derived from view settings (header/footer visibility, margins). Calculated by `getViewInsets()` in `src/utils/insets.ts`. Passed as a prop from `BooksGrid` → child components.
+- **`statusBarHeight: number`** — OS status bar height (default 24px). Stored in `themeStore`.
+- **`systemUIVisible: boolean`** — Whether the system UI (status bar, navigation bar) is currently shown. Stored in `themeStore`.
+- **`appService?.hasSafeAreaInset`** — Whether the platform requires safe area handling (mobile devices).
+
+### Top Inset Rules
+
+For UI elements anchored to the **top** of the screen (headers, close buttons, overlays):
+
+```tsx
+// When system UI is visible, use the larger of gridInsets.top and statusBarHeight
+// When system UI is hidden, use gridInsets.top alone
+style={{
+ marginTop: systemUIVisible
+ ? `${Math.max(gridInsets.top, statusBarHeight)}px`
+ : `${gridInsets.top}px`,
+}}
+```
+
+For containers that need safe area padding at the top:
+
+```tsx
+style={{
+ paddingTop: appService?.hasSafeAreaInset ? `${gridInsets.top}px` : '0px',
+}}
+```
+
+### Bottom Inset Rules
+
+For UI elements anchored to the **bottom** of the screen (footer bars, controls, progress indicators), use `gridInsets.bottom * 0.33` as padding — a fraction of the full inset since bottom bars don't need as much clearance as the home indicator area:
+
+```tsx
+style={{
+ paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0,
+}}
+```
+
+### Passing `gridInsets`
+
+When creating overlay components (image viewers, table viewers, zoom controls, etc.), always pass `gridInsets` as a prop so they can position their controls correctly:
+
+```tsx
+
+
+
+```
diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx
index f84a52cc..056d8ae6 100644
--- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx
+++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx
@@ -594,6 +594,7 @@ const FoliateViewer: React.FC<{
<>
{selectedImage && (
0 ? handlePreviousImage : undefined}
@@ -602,6 +603,7 @@ const FoliateViewer: React.FC<{
)}
{selectedTableHtml && (
setSelectedTableHtml(null)}
diff --git a/apps/readest-app/src/app/reader/components/ImageViewer.tsx b/apps/readest-app/src/app/reader/components/ImageViewer.tsx
index 012b59f3..8ac4ee9d 100644
--- a/apps/readest-app/src/app/reader/components/ImageViewer.tsx
+++ b/apps/readest-app/src/app/reader/components/ImageViewer.tsx
@@ -3,9 +3,11 @@ import React, { useState, useRef, useEffect } from 'react';
import Image from 'next/image';
import { IoChevronBack, IoChevronForward } from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
+import { Insets } from '@/types/misc';
import ZoomControls from './ZoomControls';
interface ImageViewerProps {
+ gridInsets: Insets;
src: string | null;
onClose: () => void;
onPrevious?: () => void;
@@ -18,7 +20,13 @@ const ZOOM_SPEED = 0.1;
const MOBILE_ZOOM_SPEED = 0.001;
const ZOOM_BIAS = 1.05;
-const ImageViewer: React.FC = ({ src, onClose, onPrevious, onNext }) => {
+const ImageViewer: React.FC = ({
+ src,
+ onClose,
+ onPrevious,
+ onNext,
+ gridInsets,
+}) => {
const _ = useTranslation();
const [scale, setScale] = useState(1);
const [zoomSpeed, setZoomSpeed] = useState(0.1);
@@ -391,6 +399,7 @@ const ImageViewer: React.FC = ({ src, onClose, onPrevious, onN
}}
/>
void;
@@ -13,7 +15,7 @@ const MIN_SCALE = 0.5;
const MAX_SCALE = 4;
const ZOOM_SPEED = 0.1;
-const TableViewer: React.FC = ({ html, isDarkMode, onClose }) => {
+const TableViewer: React.FC = ({ gridInsets, html, isDarkMode, onClose }) => {
const _ = useTranslation();
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
@@ -188,6 +190,7 @@ const TableViewer: React.FC = ({ html, isDarkMode, onClose })
}}
/>
void;
onZoomIn: () => void;
onZoomOut: () => void;
onReset: () => void;
}
-const ZoomControls: React.FC = ({ onClose, onZoomIn, onZoomOut, onReset }) => {
+const ZoomControls: React.FC = ({
+ gridInsets,
+ onClose,
+ onZoomIn,
+ onZoomOut,
+ onReset,
+}) => {
const _ = useTranslation();
+ const { systemUIVisible, statusBarHeight } = useThemeStore();
return (
-