From ecd0751dbb8b12ca148afc78a05f0fbf11d3db9a Mon Sep 17 00:00:00 2001
From: Hollujay <165713167+Hollujay@users.noreply.github.com>
Date: Tue, 21 Jul 2026 06:50:13 +0000
Subject: [PATCH] Add safe-area insets for notched devices (#222)
- Define --safe-area-inset-* CSS custom properties in :root using
env(safe-area-inset-*) with 0px fallback
- Apply safe-area padding to Navbar (top), Footer (bottom),
Sidebar (top), and Modal (all four sides) layout components
- Add useSafeAreaInsets hook for JS access to safe-area pixel values
- Add to index.html for iOS notch support
- Write test coverage for the hook and CSS declarations
- Document safe-area support in README
---
README.md | 15 +++++++
index.html | 2 +-
src/components/Footer.css | 2 +-
src/components/Modal.css | 4 ++
src/components/Navbar.css | 2 +-
src/components/Sidebar.css | 5 ++-
src/hooks/useSafeAreaInsets.js | 43 +++++++++++++++++++
src/index.css | 8 ++++
test/hooks/useSafeAreaInsets.test.js | 63 ++++++++++++++++++++++++++++
test/safe-area.test.js | 57 +++++++++++++++++++++++++
10 files changed, 197 insertions(+), 4 deletions(-)
create mode 100644 src/hooks/useSafeAreaInsets.js
create mode 100644 test/hooks/useSafeAreaInsets.test.js
create mode 100644 test/safe-area.test.js
diff --git a/README.md b/README.md
index 3b4c6cf..8d3df0e 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,21 @@ cp .env.example .env
Integration tests cover send-money validation, successful transfer submission,
pending button behavior and duplicate-submission prevention.
+## Notched / Rounded Devices
+
+The app supports notched and rounded-screen devices (e.g. iPhone X+,
+Android flagships) via CSS `env(safe-area-inset-*)`:
+
+- CSS custom properties are defined in `src/index.css` (`--safe-area-inset-top`,
+ `--safe-area-inset-bottom`, `--safe-area-inset-left`,
+ `--safe-area-inset-right`) with a `0px` fallback.
+- The `` tag already includes `viewport-fit=cover`.
+- Layout components (`Navbar`, `Footer`, `Sidebar`, `Modal`) consume the
+ custom properties to keep content clear of notches, rounded corners and the
+ home indicator.
+- The `useSafeAreaInsets` hook (in `src/hooks/useSafeAreaInsets.js`) exposes
+ the numeric pixel values for any component that needs them in JavaScript.
+
## Lighthouse CI
Lighthouse checks are configured in [lighthouserc.json](lighthouserc.json) and run in GitHub Actions on pull requests to the main branch. To validate locally, build the app and run:
diff --git a/index.html b/index.html
index c5ae825..8f7e1ae 100644
--- a/index.html
+++ b/index.html
@@ -3,7 +3,7 @@
-
+
RemitFlow
diff --git a/src/components/Footer.css b/src/components/Footer.css
index 88d33bc..f81d2cb 100644
--- a/src/components/Footer.css
+++ b/src/components/Footer.css
@@ -2,7 +2,7 @@
display: flex;
align-items: center;
justify-content: space-between;
- padding: 1.25rem 1.5rem;
+ padding: 1.25rem 1.5rem calc(var(--safe-area-inset-bottom) + 1.25rem);
margin-top: 2rem;
border-top: 1px solid #1f2a3a;
color: var(--color-muted);
diff --git a/src/components/Modal.css b/src/components/Modal.css
index 6628eee..4eca37b 100644
--- a/src/components/Modal.css
+++ b/src/components/Modal.css
@@ -6,6 +6,10 @@
align-items: center;
justify-content: center;
padding: 1.5rem;
+ padding-top: calc(var(--safe-area-inset-top) + 1.5rem);
+ padding-bottom: calc(var(--safe-area-inset-bottom) + 1.5rem);
+ padding-left: calc(var(--safe-area-inset-left) + 1.5rem);
+ padding-right: calc(var(--safe-area-inset-right) + 1.5rem);
background: rgba(2, 6, 23, 0.7);
}
diff --git a/src/components/Navbar.css b/src/components/Navbar.css
index 1c187f1..5ecf8f8 100644
--- a/src/components/Navbar.css
+++ b/src/components/Navbar.css
@@ -2,7 +2,7 @@
display: flex;
align-items: center;
gap: 1.5rem;
- padding: 0.875rem 1.5rem;
+ padding: calc(var(--safe-area-inset-top) + 0.875rem) 1.5rem 0.875rem;
background: var(--color-surface);
border-bottom: 1px solid #1f2a3a;
}
diff --git a/src/components/Sidebar.css b/src/components/Sidebar.css
index e0b3c50..6448a70 100644
--- a/src/components/Sidebar.css
+++ b/src/components/Sidebar.css
@@ -5,7 +5,10 @@
min-width: 240px;
background: var(--color-surface);
border-right: 1px solid #1f2a3a;
- padding: 0;
+ padding-top: var(--safe-area-inset-top);
+ padding-left: 0;
+ padding-right: 0;
+ padding-bottom: 0;
transition: width 0.25s ease, min-width 0.25s ease;
overflow: hidden;
height: 100vh;
diff --git a/src/hooks/useSafeAreaInsets.js b/src/hooks/useSafeAreaInsets.js
new file mode 100644
index 0000000..ba62b52
--- /dev/null
+++ b/src/hooks/useSafeAreaInsets.js
@@ -0,0 +1,43 @@
+import { useEffect, useState } from 'react'
+
+function readInset(name) {
+ if (typeof document === 'undefined') return 0
+ const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
+ if (!raw) return 0
+ const match = raw.match(/^(\d+(?:\.\d+)?)/)
+ return match ? Number.parseFloat(match[1]) : 0
+}
+
+/**
+ * Returns the current safe-area inset values in CSS pixels.
+ * Updates on resize / orientation change so it reflects device rotation.
+ * @returns {{ top: number, bottom: number, left: number, right: number }}
+ */
+export function useSafeAreaInsets() {
+ const [insets, setInsets] = useState(() => ({
+ top: readInset('--safe-area-inset-top'),
+ bottom: readInset('--safe-area-inset-bottom'),
+ left: readInset('--safe-area-inset-left'),
+ right: readInset('--safe-area-inset-right'),
+ }))
+
+ useEffect(() => {
+ function sync() {
+ setInsets({
+ top: readInset('--safe-area-inset-top'),
+ bottom: readInset('--safe-area-inset-bottom'),
+ left: readInset('--safe-area-inset-left'),
+ right: readInset('--safe-area-inset-right'),
+ })
+ }
+
+ window.addEventListener('resize', sync)
+ window.addEventListener('orientationchange', sync)
+ return () => {
+ window.removeEventListener('resize', sync)
+ window.removeEventListener('orientationchange', sync)
+ }
+ }, [])
+
+ return insets
+}
diff --git a/src/index.css b/src/index.css
index 4647968..c17cc9b 100644
--- a/src/index.css
+++ b/src/index.css
@@ -4,6 +4,14 @@
--color-primary: #6366f1;
--color-text: #e2e8f0;
--color-muted: #94a3b8;
+
+ /* Safe-area insets for notched / rounded devices. Env falls back to 0px
+ on browsers / devices without notch support. */
+ --safe-area-inset-top: env(safe-area-inset-top, 0px);
+ --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
+ --safe-area-inset-left: env(safe-area-inset-left, 0px);
+ --safe-area-inset-right: env(safe-area-inset-right, 0px);
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
diff --git a/test/hooks/useSafeAreaInsets.test.js b/test/hooks/useSafeAreaInsets.test.js
new file mode 100644
index 0000000..64adc96
--- /dev/null
+++ b/test/hooks/useSafeAreaInsets.test.js
@@ -0,0 +1,63 @@
+import { renderHook, act } from '@testing-library/react'
+import { describe, expect, it, beforeEach } from 'vitest'
+import { useSafeAreaInsets } from '../../src/hooks/useSafeAreaInsets.js'
+
+function setRootProperty(name, value) {
+ document.documentElement.style.setProperty(name, value)
+}
+
+function clearRootProperty(name) {
+ document.documentElement.style.removeProperty(name)
+}
+
+describe('useSafeAreaInsets', () => {
+ beforeEach(() => {
+ clearRootProperty('--safe-area-inset-top')
+ clearRootProperty('--safe-area-inset-bottom')
+ clearRootProperty('--safe-area-inset-left')
+ clearRootProperty('--safe-area-inset-right')
+ })
+
+ it('returns 0 for all edges when CSS properties are not set', () => {
+ const { result } = renderHook(() => useSafeAreaInsets())
+ expect(result.current).toEqual({ top: 0, bottom: 0, left: 0, right: 0 })
+ })
+
+ it('reads values from CSS custom properties', () => {
+ setRootProperty('--safe-area-inset-top', '44px')
+ setRootProperty('--safe-area-inset-bottom', '34px')
+ setRootProperty('--safe-area-inset-left', '0px')
+ setRootProperty('--safe-area-inset-right', '0px')
+
+ const { result } = renderHook(() => useSafeAreaInsets())
+ expect(result.current).toEqual({ top: 44, bottom: 34, left: 0, right: 0 })
+ })
+
+ it('returns fractional pixel values', () => {
+ setRootProperty('--safe-area-inset-top', '20.5px')
+ const { result } = renderHook(() => useSafeAreaInsets())
+ expect(result.current.top).toBeCloseTo(20.5)
+ })
+
+ it('updates on resize event', () => {
+ const { result } = renderHook(() => useSafeAreaInsets())
+
+ setRootProperty('--safe-area-inset-bottom', '44px')
+ act(() => {
+ window.dispatchEvent(new Event('resize'))
+ })
+
+ expect(result.current.bottom).toBe(44)
+ })
+
+ it('updates on orientationchange event', () => {
+ const { result } = renderHook(() => useSafeAreaInsets())
+
+ setRootProperty('--safe-area-inset-top', '50px')
+ act(() => {
+ window.dispatchEvent(new Event('orientationchange'))
+ })
+
+ expect(result.current.top).toBe(50)
+ })
+})
diff --git a/test/safe-area.test.js b/test/safe-area.test.js
new file mode 100644
index 0000000..8208c61
--- /dev/null
+++ b/test/safe-area.test.js
@@ -0,0 +1,57 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+function readCSS(filename) {
+ return fs.readFileSync(
+ path.resolve(process.cwd(), 'src', filename),
+ 'utf8',
+ );
+}
+
+describe('safe-area insets', () => {
+ describe('index.css', () => {
+ const css = readCSS('index.css');
+
+ it('defines --safe-area-inset-top with env() fallback', () => {
+ expect(css).toContain('--safe-area-inset-top: env(safe-area-inset-top, 0px)');
+ });
+
+ it('defines --safe-area-inset-bottom with env() fallback', () => {
+ expect(css).toContain('--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px)');
+ });
+
+ it('defines --safe-area-inset-left with env() fallback', () => {
+ expect(css).toContain('--safe-area-inset-left: env(safe-area-inset-left, 0px)');
+ });
+
+ it('defines --safe-area-inset-right with env() fallback', () => {
+ expect(css).toContain('--safe-area-inset-right: env(safe-area-inset-right, 0px)');
+ });
+ });
+
+ describe('component CSS files use safe-area variables', () => {
+ it('Navbar.css uses --safe-area-inset-top', () => {
+ const css = readCSS('components/Navbar.css');
+ expect(css).toContain('--safe-area-inset-top');
+ });
+
+ it('Footer.css uses --safe-area-inset-bottom', () => {
+ const css = readCSS('components/Footer.css');
+ expect(css).toContain('--safe-area-inset-bottom');
+ });
+
+ it('Sidebar.css uses --safe-area-inset-top', () => {
+ const css = readCSS('components/Sidebar.css');
+ expect(css).toContain('--safe-area-inset-top');
+ });
+
+ it('Modal.css uses all four safe-area variables', () => {
+ const css = readCSS('components/Modal.css');
+ expect(css).toContain('--safe-area-inset-top');
+ expect(css).toContain('--safe-area-inset-bottom');
+ expect(css).toContain('--safe-area-inset-left');
+ expect(css).toContain('--safe-area-inset-right');
+ });
+ });
+});