Match the browser/OS chrome to the theme and clear the status bar
Build and Push Docker Image / build-and-push (push) Successful in 4m30s

The app now sets theme-color, color-scheme and the page background from the chosen theme,
so the iPad status-bar area no longer shows a light band over a dark theme. Every top bar
leaves room for the status bar via a safe-top class (replacing a rule that padded every
<header>). Install defaults match the paper theme.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 11:24:58 -04:00
1 parent ec59f7c82d
commit 7fc5e4b534
12 files changed
+89 -14

No files matched your search

+2 -2
View File
@@ -9,7 +9,7 @@
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<!-- Theme / status bar --> <!-- Theme / status bar -->
<meta name="theme-color" content="#0f172a" /> <meta name="theme-color" content="#f1ece0" />
<!-- iOS PWA --> <!-- iOS PWA -->
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
@@ -20,7 +20,7 @@
<!-- Fallback icon for browsers --> <!-- Fallback icon for browsers -->
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" /> <link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
</head> </head>
<body class="bg-slate-50 text-slate-900"> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
+2 -2
View File
@@ -6,8 +6,8 @@
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"orientation": "any", "orientation": "any",
"background_color": "#f8fafc", "background_color": "#faf7f0",
"theme_color": "#0f172a", "theme_color": "#f1ece0",
"icons": [ "icons": [
{ {
"src": "/icon-192.png", "src": "/icon-192.png",
+3
View File
@@ -1,5 +1,6 @@
import { lazy, Suspense, useEffect, useRef, useState } from 'react'; import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import { getPlanSchedule, nearestReadingDay } from './utils/readingPlans.js'; import { getPlanSchedule, nearestReadingDay } from './utils/readingPlans.js';
import { applySystemChrome } from './utils/systemChrome.js';
import { AppContext, useApp } from './context/AppContext.js'; import { AppContext, useApp } from './context/AppContext.js';
import { useAudio } from './hooks/useAudio.js'; import { useAudio } from './hooks/useAudio.js';
import { useAuth } from './hooks/useAuth.js'; import { useAuth } from './hooks/useAuth.js';
@@ -59,6 +60,8 @@ const App = () => {
const reader = useBibleReader(currentPage, readerPlanProgress?.translation ?? null); const reader = useBibleReader(currentPage, readerPlanProgress?.translation ?? null);
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter, reader.readerTranslation); const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter, reader.readerTranslation);
useEffect(() => { if (currentPage !== 'reader') setReaderPlan(null); }, [currentPage]); 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({ const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
onSignIn: (user) => { onSignIn: (user) => {
setProjectIndex(switchStorageUser(user.id)); setProjectIndex(switchStorageUser(user.id));
+15
View File
@@ -1405,3 +1405,18 @@ describe('Import template', () => {
expect(await screen.findByRole('button', { name: /download template/i })).toBeEnabled(); 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');
});
});
+1 -1
View File
@@ -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 }) { export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onSettings, onDoneDrawing }) {
return ( return (
<div className="sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans"> <div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2"> <div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
<button type="button" onClick={onBack} className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] transition hover:bg-black/5" aria-label="Back to home"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button> <button type="button" onClick={onBack} className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] transition hover:bg-black/5" aria-label="Back to home"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
<button type="button" onClick={onOpenNavigator} <button type="button" onClick={onOpenNavigator}
+1 -1
View File
@@ -65,7 +65,7 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans }; const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans };
return ( return (
<div className="min-h-screen bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}> <div className="min-h-screen bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
<header className="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-4 px-4`}> <div className={`mx-auto flex h-12 ${maxWidth} items-center gap-4 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="flex shrink-0 items-center gap-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" />
+1 -1
View File
@@ -24,7 +24,7 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
return ( return (
<div className={`bg-[var(--r-bg)] font-sans text-[var(--r-text)] ${fixedHeight ? 'flex h-screen flex-col overflow-hidden' : 'min-h-screen'}`} style={themeVars(readerTheme)}> <div className={`bg-[var(--r-bg)] font-sans text-[var(--r-text)] ${fixedHeight ? 'flex h-screen flex-col overflow-hidden' : 'min-h-screen'}`} style={themeVars(readerTheme)}>
<div className="z-30 shrink-0 border-b border-[var(--r-line)] bg-[var(--r-bar)]"> <div className="safe-top z-30 shrink-0 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3"> <div className="mx-auto flex h-12 max-w-[1600px] items-center gap-3 px-3">
<button type="button" onClick={goHome} aria-label="Back to home" <button type="button" onClick={goHome} aria-label="Back to home"
className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button> className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
+4 -5
View File
@@ -15,7 +15,7 @@ body,
body { body {
margin: 0; margin: 0;
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 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; color: #0f172a;
} }
@@ -30,11 +30,10 @@ select {
font: inherit; font: inherit;
} }
/* When installed as a PWA, push header content below the status bar */ /* Top bars leave room for the status bar / notch when the app is installed or full-screen
@media all and (display-mode: standalone) { (env() is 0 in an ordinary browser tab). */
header { .safe-top {
padding-top: env(safe-area-inset-top, 0px); padding-top: env(safe-area-inset-top, 0px);
}
} }
.scrollbar-thin::-webkit-scrollbar { .scrollbar-thin::-webkit-scrollbar {
+1 -1
View File
@@ -344,7 +344,7 @@ export default function ReaderPage() {
)} )}
{!chromeVisible && ( {!chromeVisible && (
<button type="button" onClick={() => setChromeVisible(true)} aria-label="Show controls" <button type="button" onClick={() => setChromeVisible(true)} aria-label="Show controls"
className="fixed right-3 top-3 z-30 rounded-full border border-[var(--r-line)] bg-[var(--r-bar)] p-2 text-[var(--r-sub)] opacity-70 hover:opacity-100"> className="fixed right-3 top-[calc(0.75rem+env(safe-area-inset-top,0px))] z-30 rounded-full border border-[var(--r-line)] bg-[var(--r-bar)] p-2 text-[var(--r-sub)] opacity-70 hover:opacity-100">
<FocusIcon /> <FocusIcon />
</button> </button>
)} )}
+1 -1
View File
@@ -101,7 +101,7 @@ export default function ReadingPlansPage() {
return ( return (
<div className="min-h-screen bg-[var(--r-bg)] pb-16 text-[var(--r-text)]" style={themeVars(readerTheme)}> <div className="min-h-screen bg-[var(--r-bg)] pb-16 text-[var(--r-text)]" style={themeVars(readerTheme)}>
<div className="sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans"> <div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
<div className="mx-auto flex h-12 max-w-3xl items-center gap-1 px-2"> <div className="mx-auto flex h-12 max-w-3xl items-center gap-1 px-2">
<button type="button" onClick={goHome} aria-label="Back to home" className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button> <button type="button" onClick={goHome} aria-label="Back to home" className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
<h1 className="mx-auto text-sm font-medium">Reading plans</h1> <h1 className="mx-auto text-sm font-medium">Reading plans</h1>
+20
View File
@@ -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
}
+38
View File
@@ -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');
});