Put every preference in Settings; same header on every page
Build and Push Docker Image / build-and-push (push) Successful in 5m7s

- Settings > Preferences: typeface, line spacing, verse numbers, highlight colour names,
  My Studies grid/list and sort, verse-of-the-day Bible and picture, commentary sources, study templates
- Header uses one fixed width and reserves scrollbar space so the nav sits in the same place on every page

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 14:10:12 -04:00
1 parent a2185ba1dd
commit 512c6b6889
7 files changed
+204 -1

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
// Settings → Preferences: the choices that used to be reachable only from the screen they affect
// (highlight colour names, the dashboard's layout, the verse of the day, saved study templates).
import { useEffect, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { useLocalSetting } from '../../hooks/useLocalSetting.js';
import { useIqAvailable } from '../../hooks/useIq.js';
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS } from '../../utils/highlightColors.js';
import { SCENES, loadPhotoList } from '../../utils/verseBackgrounds.js';
import { BACKGROUND_KEY } from '../VerseShareImage.jsx';
const card = 'rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6';
const field = '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)]';
function Row({ label, hint, children }) {
return (
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 border-t border-[var(--r-line)] py-3 first:border-t-0">
<div className="min-w-0 flex-1 basis-48">
<p className="text-sm">{label}</p>
{hint && <p className="text-xs text-[var(--r-sub)]">{hint}</p>}
</div>
{children}
</div>
);
}
export function HighlightColours() {
const { readerColorLabels = {}, setReaderColorLabel } = useApp();
return (
<section aria-label="Highlight colours" className={card}>
<h2 className="text-base font-medium">Highlight colours</h2>
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Give each colour a meaning, such as “Promise” or “Command”. The names show in your Highlights list and exports, and sync to your other devices.</p>
{HIGHLIGHT_COLORS.map((c) => (
<div key={c} className="flex items-center gap-3 border-t border-[var(--r-line)] py-2 first:border-t-0">
<span aria-hidden="true" className="h-5 w-5 shrink-0 rounded-full border border-black/10" style={{ background: c }} />
<input aria-label={`Name for ${COLOR_NAMES[c].toLowerCase()} highlights`} value={readerColorLabels[c] ?? ''} maxLength={30}
onChange={(e) => setReaderColorLabel(c, e.target.value)} placeholder={COLOR_SUGGESTIONS[c] ?? COLOR_NAMES[c]} className={`${field} min-w-0 flex-1`} />
</div>
))}
</section>
);
}
export function DashboardSettings() {
const [view, setView] = useLocalSetting('homeView', 'grid');
const [sort, setSort] = useLocalSetting('homeSort', 'recent');
return (
<section aria-label="My Studies page" className={card}>
<h2 className="text-base font-medium">My Studies page</h2>
<Row label="Show studies as" hint="You can still switch on the page itself.">
<div role="group" aria-label="Show studies as" className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
{[['grid', 'Grid'], ['list', 'List']].map(([id, text]) => (
<button key={id} type="button" aria-pressed={view === id} onClick={() => setView(id)}
className={`px-3 py-2 text-sm ${view === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{text}</button>
))}
</div>
</Row>
<Row label="Sort by">
<select aria-label="Sort studies by" value={sort} onChange={(e) => setSort(e.target.value)} className={field}>
<option value="recent">Last edited</option>
<option value="title">Title</option>
<option value="passage">Passage</option>
</select>
</Row>
</section>
);
}
export function VerseOfDaySettings() {
const on = useIqAvailable();
const { readerTranslation } = useApp();
const [translation, setTranslation] = useLocalSetting('votd-translation', '');
const [background, setBackground] = useLocalSetting(BACKGROUND_KEY, 'auto');
const [photos, setPhotos] = useState([]);
useEffect(() => { let live = true; loadPhotoList().then((list) => { if (live) setPhotos(list); }); return () => { live = false; }; }, []);
if (!on) return null;
const shown = READER_TRANSLATIONS.some((t) => t.id === translation) ? translation : '';
return (
<section aria-label="Verse of the day" className={card}>
<h2 className="text-base font-medium">Verse of the day</h2>
<p className="mb-1 mt-1 text-sm text-[var(--r-sub)]">The card on your My Studies page. Turn on the daily reminder under Notifications.</p>
<Row label="Bible" hint="Today’s verse is chosen for everyone; its words come from the Bible you pick.">
<select aria-label="Verse of the day Bible" value={shown} onChange={(e) => setTranslation(e.target.value)} className={field}>
<option value="">Same as my default Bible ({readerTranslation})</option>
{READER_TRANSLATIONS.map((t) => <option key={t.id} value={t.id}>{t.id} · {t.name}</option>)}
</select>
</Row>
<Row label="Picture behind the verse">
<select aria-label="Verse of the day picture" value={background} onChange={(e) => setBackground(e.target.value)} className={field}>
<option value="auto">A different picture each day</option>
<option value="plain">Plain</option>
<optgroup label="Scenes">{SCENES.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}</optgroup>
{photos.length > 0 && <optgroup label="Your photos">{photos.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</optgroup>}
</select>
</Row>
</section>
);
}
export function StudyTemplates() {
const { userTemplates = [], deleteUserTemplate } = useApp();
return (
<section aria-label="Study templates" className={card}>
<h2 className="text-base font-medium">Study templates</h2>
<p className="mb-2 mt-1 text-sm text-[var(--r-sub)]">Templates you saved from a study’s Setup stage. They sync to your other devices.</p>
{userTemplates.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">None saved yet.</p>
) : (
<ul className="divide-y divide-[var(--r-line)]">
{userTemplates.map((t) => (
<li key={t.id} className="flex items-center justify-between gap-3 py-2">
<span className="min-w-0"><span className="text-sm">{t.name}</span>{t.description && <span className="block truncate text-xs text-[var(--r-sub)]">{t.description}</span>}</span>
<button type="button" onClick={() => { if (window.confirm(`Delete the template “${t.name}”? Studies already made from it keep their notes.`)) deleteUserTemplate(t.id); }}
className="shrink-0 rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Delete</button>
</li>
))}
</ul>
)}
</section>
);
}
+1 -1
View File
@@ -95,7 +95,7 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}> <div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
<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 max-w-6xl items-center gap-2 px-3 sm:gap-4 sm:px-4">
<button type="button" onClick={goHome} className="-m-2 flex shrink-0 items-center gap-2 p-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>
@@ -35,3 +35,12 @@ test('on a wide screen the destinations stay in the header', () => {
expect(nav.closest('header')).not.toBeNull(); expect(nav.closest('header')).not.toBeNull();
expect(screen.getAllByRole('button', { name: 'Account menu' })).toHaveLength(1); expect(screen.getAllByRole('button', { name: 'Account menu' })).toHaveLength(1);
}); });
test('the header is the same width on every page, whatever width the page content is', () => {
phone(false);
const { container, rerender } = render(<PageShell title="A" maxWidth="max-w-3xl"><p>x</p></PageShell>);
const inner = () => container.querySelector('header > div');
expect(inner().className).toContain('max-w-6xl');
rerender(<PageShell title="B" maxWidth="max-w-2xl"><p>x</p></PageShell>);
expect(inner().className).toContain('max-w-6xl');
});
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
const EVENT = 'local-setting';
// A preference kept in this browser's localStorage, for settings that live in more than one place (the Settings
// page and the screen they affect). Changing it in one place updates every component that reads it.
export function useLocalSetting(key, fallback) {
const read = () => { try { return localStorage.getItem(key) ?? fallback; } catch { return fallback; } };
const [value, setValue] = useState(read);
useEffect(() => {
const on = (e) => { if (!e.detail || e.detail.key === key) setValue(read()); };
window.addEventListener(EVENT, on);
window.addEventListener('storage', on);
return () => { window.removeEventListener(EVENT, on); window.removeEventListener('storage', on); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
const set = (next) => {
try { localStorage.setItem(key, next); } catch { /* storage unavailable: still applies until reload */ }
setValue(next);
window.dispatchEvent(new CustomEvent(EVENT, { detail: { key } }));
};
return [value, set];
}
+1
View File
@@ -34,6 +34,7 @@ select {
/* iPhone and iPad: don't inflate text when the phone is turned sideways, no grey flash on tap, and no /* 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. */ double-tap-to-zoom delay on controls. */
html { html {
scrollbar-gutter: stable; /* pages with and without a scrollbar line up the same */
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
text-size-adjust: 100%; text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
+24
View File
@@ -5,6 +5,9 @@ import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx'; import PageShell from '../components/study/PageShell.jsx';
import { READER_TRANSLATIONS } from '../utils/bibleText.js'; import { READER_TRANSLATIONS } from '../utils/bibleText.js';
import DataSection from '../components/settings/DataSection.jsx'; import DataSection from '../components/settings/DataSection.jsx';
import { DashboardSettings, HighlightColours, StudyTemplates, VerseOfDaySettings } from '../components/settings/MoreSettings.jsx';
import { useLocalSetting } from '../hooks/useLocalSetting.js';
import { useIqAvailable } from '../hooks/useIq.js';
import PasskeysSection from '../components/settings/PasskeysSection.jsx'; import PasskeysSection from '../components/settings/PasskeysSection.jsx';
import DevicesSection from '../components/settings/DevicesSection.jsx'; import DevicesSection from '../components/settings/DevicesSection.jsx';
import { COMMENTARIES } from '../hooks/useVerseStudy.js'; import { COMMENTARIES } from '../hooks/useVerseStudy.js';
@@ -61,11 +64,14 @@ export default function SettingsPage() {
readerWide, setReaderWide, readerWide, setReaderWide,
readerCommentary, setReaderCommentary, readerCommentary, setReaderCommentary,
audioNarrator, setAudioNarrator, audioNarrator, setAudioNarrator,
readerFont, setReaderFont, readerSpacing, setReaderSpacing, readerVerseNumbers, setReaderVerseNumbers,
settingsFocus, clearSettingsFocus, settingsFocus, clearSettingsFocus,
} = useApp(); } = useApp();
// "Back up my data" from the ⌘K palette lands on that section // "Back up my data" from the ⌘K palette lands on that section
const narrators = useLazyNarrators(audioNarrator); const narrators = useLazyNarrators(audioNarrator);
const iqOn = useIqAvailable();
const [iqMode, setIqMode] = useLocalSetting('iq-commentary-mode', '0');
const [tab, setTab] = useState(settingsFocus === 'backup' ? 'data' : 'account'); const [tab, setTab] = useState(settingsFocus === 'backup' ? 'data' : 'account');
useEffect(() => { useEffect(() => {
if (!settingsFocus) return; if (!settingsFocus) return;
@@ -284,9 +290,23 @@ export default function SettingsPage() {
{COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)} {COMMENTARIES.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select> </select>
</PrefRow> </PrefRow>
{iqOn && (
<PrefRow label="Commentary sources" hint="Classic is the short list. More sources adds IQ Bible’s many commentaries.">
<Segmented label="Commentary sources" options={[['0', 'Classic'], ['1', 'More sources']]} value={iqMode} onChange={setIqMode} />
</PrefRow>
)}
<PrefRow label="Text size" hint="Reader text."> <PrefRow label="Text size" hint="Reader text.">
<Segmented label="Text size" options={FONT_SIZES.map(([l, n]) => [n, l])} value={readerFontSize} onChange={setReaderFontSize} /> <Segmented label="Text size" options={FONT_SIZES.map(([l, n]) => [n, l])} value={readerFontSize} onChange={setReaderFontSize} />
</PrefRow> </PrefRow>
<PrefRow label="Typeface">
<Segmented label="Typeface" options={[['serif', 'Serif'], ['sans', 'Sans'], ['easy', 'Easy to read']]} value={readerFont} onChange={setReaderFont} />
</PrefRow>
<PrefRow label="Line spacing">
<Segmented label="Line spacing" options={[['snug', 'Snug'], ['normal', 'Normal'], ['airy', 'Airy']]} value={readerSpacing} onChange={setReaderSpacing} />
</PrefRow>
<PrefRow label="Verse numbers">
<Segmented label="Verse numbers" options={[[true, 'Show'], [false, 'Hide']]} value={readerVerseNumbers} onChange={setReaderVerseNumbers} />
</PrefRow>
<PrefRow label="Reading width" hint="One column, or two on a wide screen."> <PrefRow label="Reading width" hint="One column, or two on a wide screen.">
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={readerWide} onChange={setReaderWide} /> <Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={readerWide} onChange={setReaderWide} />
</PrefRow> </PrefRow>
@@ -389,6 +409,10 @@ export default function SettingsPage() {
<DataSection /> <DataSection />
)} )}
{tab === 'prefs' && <HighlightColours />}
{tab === 'prefs' && <DashboardSettings />}
{tab === 'prefs' && <VerseOfDaySettings />}
{tab === 'prefs' && <StudyTemplates />}
{tab === 'prefs' && ( {tab === 'prefs' && (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4"> <section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div> <div>
+25
View File
@@ -83,6 +83,31 @@ describe('Authentication', () => {
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument(); expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
}); });
test('every reading and display preference can be set from Settings', async () => {
stubApi({ session: user });
const u = userEvent.setup();
render(<App />);
await openAccountMenu(u);
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
await u.click(await screen.findByRole('tab', { name: 'Preferences' }));
for (const name of ['Text size', 'Typeface', 'Line spacing', 'Verse numbers', 'Reading width', 'Verse layout', 'Theme', 'Home page']) {
expect(screen.getByRole('group', { name })).toBeInTheDocument();
}
await u.click(within(screen.getByRole('group', { name: 'Typeface' })).getByRole('button', { name: 'Sans' }));
expect(localStorage.getItem('reader-font')).toBe('sans');
await u.click(within(screen.getByRole('group', { name: 'Verse numbers' })).getByRole('button', { name: 'Hide' }));
expect(localStorage.getItem('reader-verse-numbers')).toBe('off');
// highlight colour names
await u.type(screen.getByLabelText('Name for yellow highlights'), 'Promise');
expect(JSON.parse(localStorage.getItem('reader-color-labels'))['#fef08a']).toBe('Promise');
// the My Studies page
await u.click(within(screen.getByRole('group', { name: 'Show studies as' })).getByRole('button', { name: 'List' }));
expect(localStorage.getItem('homeView')).toBe('list');
await u.selectOptions(screen.getByLabelText('Sort studies by'), 'title');
expect(localStorage.getItem('homeSort')).toBe('title');
expect(screen.getByRole('heading', { name: 'Study templates' })).toBeInTheDocument();
});
test('Settings is split into tabs, one group at a time', async () => { test('Settings is split into tabs, one group at a time', async () => {
stubApi({ session: user }); stubApi({ session: user });
const u = userEvent.setup(); const u = userEvent.setup();