Make the app work better on iPhone and iPad
Build and Push Docker Image / build-and-push (push) Successful in 7m32s
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:
1 parent
bdb04cdedc
commit
d29a66f984
12 files changed
+99
-14
No files matched your search
@@ -94,7 +94,7 @@ export default function CommandPalette({ onClose }) {
|
||||
if (authUser === null && !authServerDown) return null; // the sign-in screen has nothing to search
|
||||
let lastGroup = null;
|
||||
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 className="flex items-center gap-2 border-b border-[var(--r-line)] px-4 py-3">
|
||||
<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>
|
||||
</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.map((item, i) => {
|
||||
const header = item.group !== lastGroup ? item.group : null;
|
||||
|
||||
@@ -106,14 +106,14 @@ export default function PresentMode({ title, subtitle = '', mode, script = '', t
|
||||
</div>
|
||||
|
||||
{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()
|
||||
? <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>}
|
||||
</div>
|
||||
) : (
|
||||
<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 ? (
|
||||
<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>
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function ShortcutsHelp({ onClose }) {
|
||||
|
||||
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 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">
|
||||
<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>
|
||||
|
||||
@@ -44,7 +44,7 @@ function ActiveBody({ plan, progress, onClose, actions }) {
|
||||
</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) => {
|
||||
const done = progress.completedDays.includes(i);
|
||||
return (
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function Sheet({ open, onClose, title, label, headerRight, modal
|
||||
aria-modal={modal ? 'true' : undefined}
|
||||
aria-label={typeof title === 'string' ? title : label}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
|
||||
<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)]">
|
||||
<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" />
|
||||
<span className="hidden text-sm font-medium sm:inline">Bible Study</span>
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
<span className="ml-auto text-[var(--r-sub)]">{n} selected</span>
|
||||
</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) => (
|
||||
<li key={w.strongKey}>
|
||||
<label className="flex cursor-pointer items-center gap-3 py-2.5">
|
||||
|
||||
@@ -31,6 +31,51 @@ select {
|
||||
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
|
||||
(env() is 0 in an ordinary browser tab). */
|
||||
.safe-top {
|
||||
|
||||
@@ -21,6 +21,7 @@ import NavigatorSheet from '../components/reader/NavigatorSheet.jsx';
|
||||
import SettingsSheet from '../components/reader/SettingsSheet.jsx';
|
||||
import CompareSheet from '../components/reader/CompareSheet.jsx';
|
||||
import SearchSheet from '../components/reader/SearchSheet.jsx';
|
||||
import { swipeAllowedFrom } from '../utils/swipe.js';
|
||||
import CrossRefMap from '../components/reader/CrossRefMap.jsx';
|
||||
import { useIqAvailable } from '../hooks/useIq.js';
|
||||
import MarksSheet from '../components/reader/MarksSheet.jsx';
|
||||
@@ -259,12 +260,13 @@ export default function ReaderPage() {
|
||||
|
||||
// Swipe left/right to change chapter
|
||||
const handleTouchStart = (e) => {
|
||||
if (readerDrawMode) return;
|
||||
if (readerDrawMode || !swipeAllowedFrom(e.target)) { swipeTouchRef.current = null; return; }
|
||||
const t = e.changedTouches[0];
|
||||
swipeTouchRef.current = { x: t.clientX, y: t.clientY };
|
||||
};
|
||||
const handleTouchEnd = (e) => {
|
||||
if (readerDrawMode || !swipeTouchRef.current) return;
|
||||
if (!window.getSelection()?.isCollapsed) { swipeTouchRef.current = null; return; } // dragging a text selection
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - swipeTouchRef.current.x;
|
||||
const dy = t.clientY - swipeTouchRef.current.y;
|
||||
@@ -529,7 +531,7 @@ export default function ReaderPage() {
|
||||
{/* Draw mode: scripture beside a notebook canvas */}
|
||||
{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="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}>
|
||||
<div className="mb-3">
|
||||
<span className="font-medium">{readerBook?.name} {readerChapter}</span>
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function SetupPage() {
|
||||
</div>
|
||||
<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)]" />
|
||||
<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
|
||||
.filter((v) => v.text.toLowerCase().includes(b.verseSearch.trim().toLowerCase()))
|
||||
.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>
|
||||
)}
|
||||
</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 ? (
|
||||
<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) => (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user