Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<meta name="viewport">` 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:
Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="RemitFlow - fast, low-cost cross-border remittance on Stellar." />
<title>RemitFlow</title>
</head>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Footer.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/components/Modal.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion src/components/Navbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
5 changes: 4 additions & 1 deletion src/components/Sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions src/hooks/useSafeAreaInsets.js
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
63 changes: 63 additions & 0 deletions test/hooks/useSafeAreaInsets.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
57 changes: 57 additions & 0 deletions test/safe-area.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading