mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix(pwa): follow the app's theme in the browser bar and toasts, lift toasts over the bottom bar
The theme-color tags followed the operating system's scheme, while the app's theme is its own setting and defaults to dark: a phone in light mode showed a light address bar or status bar over the dark app. The client now rewrites both tags to the theme the page painted. The toaster read Sonner's "system" for the same reason and now gets the resolved theme as well. Below 600 px Sonner stacks toasts 16 px above the bottom edge, which on a phone is on top of the bottom bar: the "new version" hint covered the tabs and the add button until dismissed. A mobile offset lifts toasts above the bar and the home indicator. pwa-mobile.spec.ts covers the hint's position and its Reload, a bottom sheet standing on a stand-in keyboard, 16 px fields in portrait and held sideways, and the sign-in card held sideways.
This commit is contained in:
1 parent
a4ad8ebe92
commit
2cd68779ec
6 files changed
+357
-7
No files matched your search
+5
-2
@@ -9,6 +9,7 @@ import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
|
||||
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
|
||||
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
|
||||
import { isKeyMismatch } from "@/lib/boot/key-mismatch-state";
|
||||
import { THEME_COLOR } from "@/lib/pwa/theme-color";
|
||||
import { KeyMismatchPage } from "./key-mismatch-page";
|
||||
|
||||
const inter = Inter({
|
||||
@@ -71,9 +72,11 @@ export const viewport: Viewport = {
|
||||
// the active palette. The hex values are the resolved background of
|
||||
// `--background` from `app/globals.css` for each theme so the bar
|
||||
// edge never seams against the page on cold paint.
|
||||
// The client rewrites both to the app's own theme once it is known
|
||||
// (`applyThemeColor`), since that need not be the operating system's.
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#f3f2f5" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#282a36" },
|
||||
{ media: "(prefers-color-scheme: light)", color: THEME_COLOR.light },
|
||||
{ media: "(prefers-color-scheme: dark)", color: THEME_COLOR.dark },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { I18nProvider, useTranslations } from "@/lib/i18n/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { toast } from "sonner";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { applyThemeColor } from "@/lib/pwa/theme-color";
|
||||
import { KeyboardInsetBridge } from "@/components/keyboard-inset-bridge";
|
||||
import { VersionPoller } from "@/components/version-poller";
|
||||
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
|
||||
@@ -84,6 +85,9 @@ function getSystemTheme(): "dark" | "light" {
|
||||
function applyTheme(resolved: "dark" | "light") {
|
||||
document.documentElement.classList.remove("dark", "light");
|
||||
document.documentElement.classList.add(resolved);
|
||||
// The browser bar and an installed app's status bar follow the theme the
|
||||
// page painted, not the operating system's scheme (see theme-color.ts).
|
||||
applyThemeColor(document, resolved);
|
||||
}
|
||||
|
||||
// ── Theme as an external store ───────────────────────
|
||||
@@ -378,6 +382,33 @@ function OfflineMutationToaster() {
|
||||
|
||||
// ── Root Providers ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* The app-wide toaster.
|
||||
*
|
||||
* Its theme is the one the page painted, not `"system"`: the app's theme is
|
||||
* its own setting (dark by default), and a phone in light mode showed light
|
||||
* toasts over the dark app.
|
||||
*
|
||||
* Below 600 px Sonner lays toasts across the bottom edge, 16 px up, which on
|
||||
* a phone is on top of the bottom bar: the "new version" hint sat over the
|
||||
* tabs and the add button until it was dismissed. The mobile offset lifts
|
||||
* them above the bar and the home indicator. From `md` the bar is gone and
|
||||
* the desktop offset applies.
|
||||
*/
|
||||
function AppToaster() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
return (
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
richColors
|
||||
theme={resolvedTheme}
|
||||
mobileOffset={{
|
||||
bottom: "calc(4rem + env(safe-area-inset-bottom, 0px) + 0.75rem)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Providers({
|
||||
children,
|
||||
initialLocale,
|
||||
@@ -405,7 +436,7 @@ export function Providers({
|
||||
<SharedRecordGrantLossBridge />
|
||||
<RecordSessionFenceBridge />
|
||||
{children}
|
||||
<Toaster position="bottom-right" richColors />
|
||||
<AppToaster />
|
||||
<KeyboardInsetBridge />
|
||||
<VersionPoller />
|
||||
<ServiceWorkerRegistrar />
|
||||
|
||||
@@ -12,10 +12,9 @@ import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
// `theme="system"` lets Sonner match the resolved theme on the
|
||||
// root element (set pre-paint by the inline theme script in
|
||||
// `app/layout.tsx`) instead of pinning dark — on light-mode the
|
||||
// hardcoded dark toaster contrasted badly against the page.
|
||||
// A fallback only: Sonner's `"system"` follows the operating
|
||||
// system's scheme, not the app's theme, so the app's toaster passes
|
||||
// its resolved theme (`AppToaster` in `providers.tsx`).
|
||||
theme="system"
|
||||
className="toaster group"
|
||||
icons={{
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { applyThemeColor, THEME_COLOR } from "@/lib/pwa/theme-color";
|
||||
|
||||
function fakeMeta(content: string) {
|
||||
const attrs = new Map<string, string>([["content", content]]);
|
||||
return {
|
||||
attrs,
|
||||
setAttribute: (k: string, v: string) => attrs.set(k, v),
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyThemeColor", () => {
|
||||
it("rewrites both media-keyed tags to the theme the page painted", () => {
|
||||
// A phone in light mode, the app in its dark default: the light tag is
|
||||
// the one the browser applies, and it must carry the dark colour.
|
||||
const light = fakeMeta(THEME_COLOR.light);
|
||||
const dark = fakeMeta(THEME_COLOR.dark);
|
||||
const doc = {
|
||||
querySelectorAll: (selector: string) => {
|
||||
expect(selector).toBe('meta[name="theme-color"]');
|
||||
return [light, dark];
|
||||
},
|
||||
} as unknown as Document;
|
||||
|
||||
applyThemeColor(doc, "dark");
|
||||
expect(light.attrs.get("content")).toBe(THEME_COLOR.dark);
|
||||
expect(dark.attrs.get("content")).toBe(THEME_COLOR.dark);
|
||||
|
||||
applyThemeColor(doc, "light");
|
||||
expect(light.attrs.get("content")).toBe(THEME_COLOR.light);
|
||||
expect(dark.attrs.get("content")).toBe(THEME_COLOR.light);
|
||||
});
|
||||
|
||||
it("matches each theme's --background in globals.css", () => {
|
||||
const css = readFileSync(
|
||||
resolve(__dirname, "../../../app/globals.css"),
|
||||
"utf8",
|
||||
);
|
||||
// The dark palette is declared in hex; the bar colour must be it exactly.
|
||||
expect(css).toMatch(
|
||||
new RegExp(`--background:\\s*${THEME_COLOR.dark};`, "i"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* The browser-chrome colour of each theme: the Android address bar, the
|
||||
* installed app's title bar, and the status bar of an installed iOS app.
|
||||
*
|
||||
* Each value is the resolved `--background` of its theme in
|
||||
* `app/globals.css`, so the bar never seams against the page on a cold paint.
|
||||
* The root layout declares both as `<meta name="theme-color" media=…>` keyed on
|
||||
* the operating system's scheme, which is all a server render can know.
|
||||
*
|
||||
* The app's own theme is not the operating system's, though: with nothing
|
||||
* stored it defaults to dark, and a person can pick light or dark by hand. A
|
||||
* phone in light mode running HealthLog in dark mode then showed a light bar
|
||||
* over a dark page. `applyThemeColor` rewrites both tags to the theme the page
|
||||
* actually painted, whichever scheme the media query would pick.
|
||||
*/
|
||||
export const THEME_COLOR = {
|
||||
light: "#f3f2f5",
|
||||
dark: "#282a36",
|
||||
} as const;
|
||||
|
||||
export function applyThemeColor(
|
||||
doc: Pick<Document, "querySelectorAll">,
|
||||
resolved: keyof typeof THEME_COLOR,
|
||||
): void {
|
||||
for (const meta of doc.querySelectorAll<HTMLMetaElement>(
|
||||
'meta[name="theme-color"]',
|
||||
)) {
|
||||
meta.setAttribute("content", THEME_COLOR[resolved]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user