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'); + }); + }); +});