Make the app work better on iPhone and iPad
Build and Push Docker Image / build-and-push (push) Successful in 7m32s

- Text fields are at least 16px on touch screens (stops iOS zooming on focus)
- Chapter swipe ignores sheets, fields, sliders, sideways-scrolling rows and text selection
- vh -> dvh for sheets, palette and dialogs
- Bigger tap targets for small links, chips and the logo; no double-tap delay
- No tap flash or landscape text inflation

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 13:08:07 -04:00
1 parent bdb04cdedc
commit d29a66f984
12 files changed
+99 -14

No files matched your search

+2 -2
View File
@@ -94,7 +94,7 @@ export default function CommandPalette({ onClose }) {
if (authUser === null && !authServerDown) return null; // the sign-in screen has nothing to search if (authUser === null && !authServerDown) return null; // the sign-in screen has nothing to search
let lastGroup = null; let lastGroup = null;
return ( return (
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-start justify-center bg-black/40 px-3 pt-[12vh] font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}> <div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-start justify-center bg-black/40 px-3 pt-[12dvh] font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div role="dialog" aria-modal="true" aria-label="Search and go to" className="w-full max-w-xl overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] shadow-2xl"> <div role="dialog" aria-modal="true" aria-label="Search and go to" className="w-full max-w-xl overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] shadow-2xl">
<div className="flex items-center gap-2 border-b border-[var(--r-line)] px-4 py-3"> <div className="flex items-center gap-2 border-b border-[var(--r-line)] px-4 py-3">
<span className="text-[var(--r-sub)]"><SearchIcon /></span> <span className="text-[var(--r-sub)]"><SearchIcon /></span>
@@ -107,7 +107,7 @@ export default function CommandPalette({ onClose }) {
/> />
<kbd className="hidden rounded border border-[var(--r-line)] px-1.5 py-0.5 text-[10px] text-[var(--r-sub)] sm:block">esc</kbd> <kbd className="hidden rounded border border-[var(--r-line)] px-1.5 py-0.5 text-[10px] text-[var(--r-sub)] sm:block">esc</kbd>
</div> </div>
<ul id="palette-list" role="listbox" ref={listRef} className="max-h-[50vh] overflow-y-auto p-2"> <ul id="palette-list" role="listbox" ref={listRef} className="max-h-[50dvh] overflow-y-auto p-2">
{items.length === 0 && <li className="px-3 py-6 text-center text-sm text-[var(--r-sub)]">Nothing found. Try a book and chapter, like “romans 8”.</li>} {items.length === 0 && <li className="px-3 py-6 text-center text-sm text-[var(--r-sub)]">Nothing found. Try a book and chapter, like “romans 8”.</li>}
{items.map((item, i) => { {items.map((item, i) => {
const header = item.group !== lastGroup ? item.group : null; const header = item.group !== lastGroup ? item.group : null;
+3 -3
View File
@@ -106,14 +106,14 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t
</div> </div>
{mode === 'script' ? ( {mode === 'script' ? (
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8vh]" style={{ scrollBehavior: 'auto' }}> <div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8dvh]" style={{ scrollBehavior: 'auto' }}>
{script.trim() {script.trim()
? <div className="mx-auto max-w-[46em] whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.5 }}>{script}<div style={{ height: '50vh' }} aria-hidden="true" /></div> ? <div className="mx-auto max-w-[46em] whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.5 }}>{script}<div style={{ height: '50dvh' }} aria-hidden="true" /></div>
: <p className="mt-20 text-center text-lg opacity-70">There’s no script to present yet. Write one on the Script page.</p>} : <p className="mt-20 text-center text-lg opacity-70">There’s no script to present yet. Write one on the Script page.</p>}
</div> </div>
) : ( ) : (
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6vh]"> <div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6dvh]">
{step ? ( {step ? (
<div className="mx-auto max-w-[46em] font-serif" style={{ fontSize, lineHeight: 1.4 }}> <div className="mx-auto max-w-[46em] font-serif" style={{ fontSize, lineHeight: 1.4 }}>
<p className="font-sans text-[0.4em] uppercase tracking-[0.16em] opacity-60">{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {steps.length}{step.minutes ? ` · ${step.minutes} min` : ''}</p> <p className="font-sans text-[0.4em] uppercase tracking-[0.16em] opacity-60">{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {steps.length}{step.minutes ? ` · ${step.minutes} min` : ''}</p>
+1 -1
View File
@@ -53,7 +53,7 @@ export default function ShortcutsHelp({ onClose }) {
return ( return (
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-3 font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}> <div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-3 font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" className="max-h-[80vh] w-full max-w-sm overflow-y-auto rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5 text-[var(--r-text)] shadow-2xl"> <div role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" className="max-h-[80dvh] w-full max-w-sm overflow-y-auto rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5 text-[var(--r-text)] shadow-2xl">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-medium">Keyboard shortcuts</h2> <h2 className="text-base font-medium">Keyboard shortcuts</h2>
<button ref={closeRef} type="button" onClick={onClose} className="rounded-lg px-2 py-1 text-sm text-[var(--r-sub)] hover:bg-black/5">Close</button> <button ref={closeRef} type="button" onClick={onClose} className="rounded-lg px-2 py-1 text-sm text-[var(--r-sub)] hover:bg-black/5">Close</button>
+1 -1
View File
@@ -44,7 +44,7 @@ function ActiveBody({ plan, progress, onClose, actions }) {
</div> </div>
</div> </div>
)} )}
<div className="max-h-[38vh] overflow-y-auto rounded-xl border border-[var(--r-line)]"> <div className="max-h-[38dvh] overflow-y-auto rounded-xl border border-[var(--r-line)]">
{schedule.map((refs, i) => { {schedule.map((refs, i) => {
const done = progress.completedDays.includes(i); const done = progress.completedDays.includes(i);
return ( return (
+1 -1
View File
@@ -46,7 +46,7 @@ export default function Sheet({ open, onClose, title, label, headerRight, modal
aria-modal={modal ? 'true' : undefined} aria-modal={modal ? 'true' : undefined}
aria-label={typeof title === 'string' ? title : label} aria-label={typeof title === 'string' ? title : label}
tabIndex={-1} tabIndex={-1}
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl focus:outline-none border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${ className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72dvh] flex-col rounded-t-2xl focus:outline-none border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${
pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[var(--reader-pane,24rem)] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2' pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[var(--reader-pane,24rem)] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2'
}`} }`}
> >
+1 -1
View File
@@ -70,7 +70,7 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
<a href="#content" className="skip-link">Skip to content</a> <a href="#content" className="skip-link">Skip to content</a>
<header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]"> <header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className={`mx-auto flex h-12 ${maxWidth} items-center gap-2 px-3 sm:gap-4 sm:px-4`}> <div className={`mx-auto flex h-12 ${maxWidth} items-center gap-2 px-3 sm:gap-4 sm:px-4`}>
<button type="button" onClick={goHome} className="flex shrink-0 items-center gap-2" aria-label="Bible Study home"> <button type="button" onClick={goHome} className="-m-2 flex shrink-0 items-center gap-2 p-2" aria-label="Bible Study home">
<img src="/logo.svg" alt="" className="h-6 w-6" /> <img src="/logo.svg" alt="" className="h-6 w-6" />
<span className="hidden text-sm font-medium sm:inline">Bible Study</span> <span className="hidden text-sm font-medium sm:inline">Bible Study</span>
</button> </button>
+1 -1
View File
@@ -16,7 +16,7 @@ export default function SuggestWordsSheet({ wordStudy }) {
<button type="button" onClick={() => setSuggestSelection(new Set())} className="text-[var(--r-accent)] hover:underline">Select none</button> <button type="button" onClick={() => setSuggestSelection(new Set())} className="text-[var(--r-accent)] hover:underline">Select none</button>
<span className="ml-auto text-[var(--r-sub)]">{n} selected</span> <span className="ml-auto text-[var(--r-sub)]">{n} selected</span>
</div> </div>
<ul className="max-h-[40vh] divide-y divide-[var(--r-line)] overflow-y-auto rounded-xl border border-[var(--r-line)] px-3"> <ul className="max-h-[40dvh] divide-y divide-[var(--r-line)] overflow-y-auto rounded-xl border border-[var(--r-line)] px-3">
{suggestModal.words.map((w) => ( {suggestModal.words.map((w) => (
<li key={w.strongKey}> <li key={w.strongKey}>
<label className="flex cursor-pointer items-center gap-3 py-2.5"> <label className="flex cursor-pointer items-center gap-3 py-2.5">
+45
View File
@@ -31,6 +31,51 @@ select {
font: inherit; font: inherit;
} }
/* iPhone and iPad: don't inflate text when the phone is turned sideways, no grey flash on tap, and no
double-tap-to-zoom delay on controls. */
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
button,
a,
select,
summary,
label,
input,
[role='button'],
[role='tab'],
[role='option'],
[role='menuitem'] {
touch-action: manipulation;
}
/* iOS Safari zooms the whole page when a text field under 16px gets focus (and doesn't zoom back).
On touch screens, text fields are at least 16px. */
@media (pointer: coarse) {
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='color']):not([type='file']):not([type='button']):not([type='submit']),
select:not([data-keep-size]),
textarea:not([data-keep-size]) {
font-size: max(16px, 1em);
}
/* Small text links and buttons (no padding of their own) get a finger-sized height. */
:is(button, a).text-xs:not([class^='p-'], [class^='py-'], [class*=' p-'], [class*=' py-']) {
padding-block: 0.55rem;
}
/* Small pill buttons (tags, chips) keep their look but get a bigger area to tap. */
:where(button.rounded-full) {
position: relative;
}
:where(button.rounded-full)::after {
content: '';
position: absolute;
inset: -7px -3px;
}
}
/* Top bars leave room for the status bar / notch when the app is installed or full-screen /* Top bars leave room for the status bar / notch when the app is installed or full-screen
(env() is 0 in an ordinary browser tab). */ (env() is 0 in an ordinary browser tab). */
.safe-top { .safe-top {
+4 -2
View File
@@ -21,6 +21,7 @@ import NavigatorSheet from '../components/reader/NavigatorSheet.jsx';
import SettingsSheet from '../components/reader/SettingsSheet.jsx'; import SettingsSheet from '../components/reader/SettingsSheet.jsx';
import CompareSheet from '../components/reader/CompareSheet.jsx'; import CompareSheet from '../components/reader/CompareSheet.jsx';
import SearchSheet from '../components/reader/SearchSheet.jsx'; import SearchSheet from '../components/reader/SearchSheet.jsx';
import { swipeAllowedFrom } from '../utils/swipe.js';
import CrossRefMap from '../components/reader/CrossRefMap.jsx'; import CrossRefMap from '../components/reader/CrossRefMap.jsx';
import { useIqAvailable } from '../hooks/useIq.js'; import { useIqAvailable } from '../hooks/useIq.js';
import MarksSheet from '../components/reader/MarksSheet.jsx'; import MarksSheet from '../components/reader/MarksSheet.jsx';
@@ -259,12 +260,13 @@ export default function ReaderPage() {
// Swipe left/right to change chapter // Swipe left/right to change chapter
const handleTouchStart = (e) => { const handleTouchStart = (e) => {
if (readerDrawMode) return; if (readerDrawMode || !swipeAllowedFrom(e.target)) { swipeTouchRef.current = null; return; }
const t = e.changedTouches[0]; const t = e.changedTouches[0];
swipeTouchRef.current = { x: t.clientX, y: t.clientY }; swipeTouchRef.current = { x: t.clientX, y: t.clientY };
}; };
const handleTouchEnd = (e) => { const handleTouchEnd = (e) => {
if (readerDrawMode || !swipeTouchRef.current) return; if (readerDrawMode || !swipeTouchRef.current) return;
if (!window.getSelection()?.isCollapsed) { swipeTouchRef.current = null; return; } // dragging a text selection
const t = e.changedTouches[0]; const t = e.changedTouches[0];
const dx = t.clientX - swipeTouchRef.current.x; const dx = t.clientX - swipeTouchRef.current.x;
const dy = t.clientY - swipeTouchRef.current.y; const dy = t.clientY - swipeTouchRef.current.y;
@@ -529,7 +531,7 @@ export default function ReaderPage() {
{/* Draw mode: scripture beside a notebook canvas */} {/* Draw mode: scripture beside a notebook canvas */}
{readerDrawMode && ( {readerDrawMode && (
<div className="mx-auto flex max-w-7xl flex-col items-start gap-4 px-4 py-4 pb-8 sm:flex-row"> <div className="mx-auto flex max-w-7xl flex-col items-start gap-4 px-4 py-4 pb-8 sm:flex-row">
<div className="w-full rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4 font-serif text-sm leading-relaxed sm:sticky sm:top-16 sm:max-h-[80vh] sm:w-72 sm:shrink-0 sm:overflow-y-auto" <div className="w-full rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4 font-serif text-sm leading-relaxed sm:sticky sm:top-16 sm:max-h-[80dvh] sm:w-72 sm:shrink-0 sm:overflow-y-auto"
onPointerUp={handleSelectionEnd}> onPointerUp={handleSelectionEnd}>
<div className="mb-3"> <div className="mb-3">
<span className="font-medium">{readerBook?.name} {readerChapter}</span> <span className="font-medium">{readerBook?.name} {readerChapter}</span>
+2 -2
View File
@@ -113,7 +113,7 @@ export default function SetupPage() {
</div> </div>
<input type="text" value={b.verseSearch} onChange={(e) => b.setVerseSearch(e.target.value)} placeholder="Search verses in this chapter…" <input type="text" value={b.verseSearch} onChange={(e) => b.setVerseSearch(e.target.value)} placeholder="Search verses in this chapter…"
className="mb-3 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" /> className="mb-3 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
<div data-testid="verse-list" className="max-h-[68vh] overflow-y-auto rounded-xl bg-[var(--r-bg)] px-3 py-3 font-serif text-[1.02rem] leading-[1.8]"> <div data-testid="verse-list" className="max-h-[68dvh] overflow-y-auto rounded-xl bg-[var(--r-bg)] px-3 py-3 font-serif text-[1.02rem] leading-[1.8]">
{activeChapter.verses {activeChapter.verses
.filter((v) => v.text.toLowerCase().includes(b.verseSearch.trim().toLowerCase())) .filter((v) => v.text.toLowerCase().includes(b.verseSearch.trim().toLowerCase()))
.map((verse) => { .map((verse) => {
@@ -181,7 +181,7 @@ export default function SetupPage() {
<button type="button" onClick={() => b.splitAfterVerse(lastVerse)} className={smallBtn} title="Make one chunk from the last chunk to the end of the chapter">Chunk the rest</button> <button type="button" onClick={() => b.splitAfterVerse(lastVerse)} className={smallBtn} title="Make one chunk from the last chunk to the end of the chapter">Chunk the rest</button>
)} )}
</div> </div>
<div className="max-h-[42vh] space-y-2 overflow-y-auto"> <div className="max-h-[42dvh] space-y-2 overflow-y-auto">
{activeChapter.chunks.length === 0 ? ( {activeChapter.chunks.length === 0 ? (
<p className="rounded-xl border border-dashed border-[var(--r-line)] p-3 text-sm text-[var(--r-sub)]">No chunks yet. Click between verses, or suggest them from the headings.</p> <p className="rounded-xl border border-dashed border-[var(--r-line)] p-3 text-sm text-[var(--r-sub)]">No chunks yet. Click between verses, or suggest them from the headings.</p>
) : activeChapter.chunks.map((chunk, index) => ( ) : activeChapter.chunks.map((chunk, index) => (
+12
View File
@@ -0,0 +1,12 @@
// Swiping sideways on the reading page turns the chapter. It shouldn't when the finger is on something that has its
// own sideways gesture: a sheet, a form field, or a row that scrolls sideways.
const OWN_GESTURE = 'input, textarea, select, [role="dialog"], [role="slider"], [data-no-swipe]';
export function swipeAllowedFrom(target) {
if (!target?.closest) return true;
if (target.closest(OWN_GESTURE)) return false;
for (let el = target; el && el !== document.body; el = el.parentElement) {
if (el.scrollWidth > el.clientWidth + 1 && /(auto|scroll)/.test(getComputedStyle(el).overflowX)) return false;
}
return true;
}
+26
View File
@@ -0,0 +1,26 @@
import { afterEach, expect, test } from 'vitest';
import { swipeAllowedFrom } from './swipe.js';
afterEach(() => { document.body.innerHTML = ''; });
const mount = (html) => { document.body.innerHTML = html; return document.body.firstElementChild; };
test('swiping on plain text is allowed', () => {
const root = mount('<div><p id="t">verse</p></div>');
expect(swipeAllowedFrom(root.querySelector('#t'))).toBe(true);
expect(swipeAllowedFrom(null)).toBe(true);
});
test('sheets, fields and sliders keep their own sideways gestures', () => {
const root = mount('<div><div role="dialog"><button id="a">x</button></div><input id="b"><div data-no-swipe><span id="c"></span></div></div>');
expect(swipeAllowedFrom(root.querySelector('#a'))).toBe(false);
expect(swipeAllowedFrom(root.querySelector('#b'))).toBe(false);
expect(swipeAllowedFrom(root.querySelector('#c'))).toBe(false);
});
test('a row that scrolls sideways is left alone', () => {
const root = mount('<div id="row" style="overflow-x:auto"><span id="s"></span></div>');
const row = root;
Object.defineProperty(row, 'scrollWidth', { value: 500, configurable: true });
Object.defineProperty(row, 'clientWidth', { value: 200, configurable: true });
expect(swipeAllowedFrom(row.querySelector('#s'))).toBe(false);
});