diff --git a/index.html b/index.html index eb2d9d8..b96c0e2 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,7 @@ - + @@ -20,7 +20,7 @@ - +
diff --git a/public/manifest.json b/public/manifest.json index e2f9c0d..64368ac 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -6,8 +6,8 @@ "scope": "/", "display": "standalone", "orientation": "any", - "background_color": "#f8fafc", - "theme_color": "#0f172a", + "background_color": "#faf7f0", + "theme_color": "#f1ece0", "icons": [ { "src": "/icon-192.png", diff --git a/src/App.jsx b/src/App.jsx index 0f39a66..6cb7586 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,6 @@ import { lazy, Suspense, useEffect, useRef, useState } from 'react'; import { getPlanSchedule, nearestReadingDay } from './utils/readingPlans.js'; +import { applySystemChrome } from './utils/systemChrome.js'; import { AppContext, useApp } from './context/AppContext.js'; import { useAudio } from './hooks/useAudio.js'; import { useAuth } from './hooks/useAuth.js'; @@ -59,6 +60,8 @@ const App = () => { const reader = useBibleReader(currentPage, readerPlanProgress?.translation ?? null); const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter, reader.readerTranslation); useEffect(() => { if (currentPage !== 'reader') setReaderPlan(null); }, [currentPage]); + // Keep the browser/OS chrome (status bar area, overscroll, scrollbars) in the chosen theme + useEffect(() => { applySystemChrome(reader.readerTheme); }, [reader.readerTheme]); const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({ onSignIn: (user) => { setProjectIndex(switchStorageUser(user.id)); diff --git a/src/App.test.jsx b/src/App.test.jsx index 53bf574..c4548ac 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -1405,3 +1405,18 @@ describe('Import template', () => { expect(await screen.findByRole('button', { name: /download template/i })).toBeEnabled(); }); }); + +describe('System chrome follows the theme', () => { + test('the app tells the browser/OS the theme colour and scheme, and updates it when the theme changes', async () => { + localStorage.setItem('reader-theme', 'dark'); + const user = userEvent.setup(); + await renderApp(); + const themeColor = () => document.head.querySelector('meta[name="theme-color"]')?.getAttribute('content'); + expect(themeColor()).toBe('#262421'); + expect(document.documentElement.style.colorScheme).toBe('dark'); + await user.click(screen.getByRole('button', { name: /account menu/i })); + await user.click(screen.getByRole('button', { name: /^paper$/i })); + expect(themeColor()).toBe('#f1ece0'); + expect(document.documentElement.style.colorScheme).toBe('light'); + }); +}); diff --git a/src/components/reader/ReaderBars.jsx b/src/components/reader/ReaderBars.jsx index bc0e1c4..575365d 100644 --- a/src/components/reader/ReaderBars.jsx +++ b/src/components/reader/ReaderBars.jsx @@ -7,7 +7,7 @@ const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(- export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onSettings, onDoneDrawing }) { return ( -
+
diff --git a/src/index.css b/src/index.css index e6636bd..3a3d376 100644 --- a/src/index.css +++ b/src/index.css @@ -15,7 +15,7 @@ body, body { margin: 0; font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; - background: #f8fafc; + background: #faf7f0; /* the default (paper) theme; replaced by the chosen theme at runtime */ color: #0f172a; } @@ -30,11 +30,10 @@ select { font: inherit; } -/* When installed as a PWA, push header content below the status bar */ -@media all and (display-mode: standalone) { - header { - padding-top: env(safe-area-inset-top, 0px); - } +/* 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 { + padding-top: env(safe-area-inset-top, 0px); } .scrollbar-thin::-webkit-scrollbar { diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx index 90ec74b..c084367 100644 --- a/src/pages/ReaderPage.jsx +++ b/src/pages/ReaderPage.jsx @@ -344,7 +344,7 @@ export default function ReaderPage() { )} {!chromeVisible && ( )} diff --git a/src/pages/ReadingPlansPage.jsx b/src/pages/ReadingPlansPage.jsx index 665e25e..c91f39f 100644 --- a/src/pages/ReadingPlansPage.jsx +++ b/src/pages/ReadingPlansPage.jsx @@ -101,7 +101,7 @@ export default function ReadingPlansPage() { return (
-
+

Reading plans

diff --git a/src/utils/systemChrome.js b/src/utils/systemChrome.js new file mode 100644 index 0000000..ab75616 --- /dev/null +++ b/src/utils/systemChrome.js @@ -0,0 +1,20 @@ +// Tells the browser / OS what the page looks like, so the parts it draws itself (the iPad and +// iPhone status bar area, the address bar, overscroll, scrollbars, form controls) match the +// chosen theme instead of defaulting to a light page. +import { READER_THEMES } from '../components/reader/readerThemes.js'; + +export function applySystemChrome(themeId, doc = document) { + const theme = READER_THEMES[themeId] ?? READER_THEMES.paper; + const { '--r-bg': bg, '--r-bar': bar } = theme.vars; + const root = doc.documentElement; + root.style.backgroundColor = bg; + root.style.colorScheme = themeId === 'dark' ? 'dark' : 'light'; + if (doc.body) doc.body.style.backgroundColor = bg; + let meta = doc.querySelector('meta[name="theme-color"]'); + if (!meta) { + meta = doc.createElement('meta'); + meta.setAttribute('name', 'theme-color'); + doc.head.appendChild(meta); + } + meta.setAttribute('content', bar); // matches the top bar, so the status bar area blends into it +} diff --git a/src/utils/systemChrome.test.js b/src/utils/systemChrome.test.js new file mode 100644 index 0000000..4671006 --- /dev/null +++ b/src/utils/systemChrome.test.js @@ -0,0 +1,38 @@ +import { test, expect, beforeEach } from 'vitest'; +import { applySystemChrome } from './systemChrome.js'; +import { READER_THEMES } from '../components/reader/readerThemes.js'; + +beforeEach(() => { + document.head.querySelectorAll('meta[name="theme-color"]').forEach((m) => m.remove()); + document.documentElement.removeAttribute('style'); + document.body.removeAttribute('style'); +}); + +const meta = () => document.head.querySelector('meta[name="theme-color"]'); + +test('each theme paints the page background, the status-bar colour and the colour scheme', () => { + for (const [id, theme] of Object.entries(READER_THEMES)) { + applySystemChrome(id); + expect(meta().getAttribute('content')).toBe(theme.vars['--r-bar']); + expect(document.documentElement.style.backgroundColor).not.toBe(''); + expect(document.body.style.backgroundColor).not.toBe(''); + expect(document.documentElement.style.colorScheme).toBe(id === 'dark' ? 'dark' : 'light'); + } +}); + +test('an existing theme-color tag is updated, not duplicated', () => { + const tag = document.createElement('meta'); + tag.setAttribute('name', 'theme-color'); + tag.setAttribute('content', '#000000'); + document.head.appendChild(tag); + applySystemChrome('sepia'); + applySystemChrome('dark'); + expect(document.head.querySelectorAll('meta[name="theme-color"]')).toHaveLength(1); + expect(tag.getAttribute('content')).toBe(READER_THEMES.dark.vars['--r-bar']); +}); + +test('an unknown theme falls back to paper', () => { + applySystemChrome('nope'); + expect(meta().getAttribute('content')).toBe(READER_THEMES.paper.vars['--r-bar']); + expect(document.documentElement.style.colorScheme).toBe('light'); +});